游戏服务器中用得最多的就是gen_server,比如游戏中的Player进程,由于gen_server提供的完善的进程设施,我们无需过多地担心进程崩溃而造成的数据丢失的问题(至少还有个terminate用于做善后工作)。因此在进行数据写回时,可以通过定时写回的机制来减轻数据库负担。这一点也是C服务器望尘莫及的。

落地流程

落地时机应由PlayerManager触发,PlayerManager管理所有的Player进程,每隔一段时间进行数据落地。为了避免同时对所有玩家落地造成的热点,可以将Player进程简单分区,每次对其中一个区进行落地,如此轮流。

落地操作交由Player进程,因为我们的绝大部分关于Player的数据都是放在进程字典中的。

Player进程首先遍历其相关的所有Model,取出其中变化的数据,然后更新数据库。

阅读全文 »

接触Erlang不到两个月时间,之前一直用C++开发。Erlang这门语言确实带给我更多的思考。

并发模型

在C++游戏服务器中,我们想要实现一个logger,支持多线程调用。因此该日志系统必须是线程安全的(c++标准输出std::cout不是线程安全的)。对应的主要有两种实现策略:

1.共享资源

常规的思维是,为了实现这个功能,去定义一个接口(函数或类),可提供给所有用户(线程)使用。但由于多个用户共享一个IO设备资源,因此需要在接口内部通过锁或其它同步方式来实现对资源的访问控制。而锁的设计与调试历来是并发程序中最耗费精力的一部分,并且由于代码在调用线程的上下文中执行,因此接口内部发生故障也会影响到调用线程,如死循环,内存越界等。即隔离性差(包括线程之间的隔离,和模块之间的解耦)。

阅读全文 »

erlang 热更是指在erlang系统不停止运行的情况下,对模块代码进行更新的特性,这也是erlang最神奇的特性之一。特别适用于游戏服务器,做活动更新,漏洞修复等。

一. 简单示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
%% 示例一 
-module(test).

-export([start/0, run/0]).

f() ->
io:format("this is old code~n").

run() ->
f(),
timer:sleep(5000),
?MODULE:run().

start() ->
spawn(fun() -> run() end).
阅读全文 »

mnesia是基于Erlang的分布式数据库管理系统,是Erlang OTP的重要组件。

基础特性

1. 分布式的ets

mnesia数据库被组织为一个表的集合,每个表由记录(通常被定义为Erlang Record)构成,表本身也包含一些属性,如类型,位置和持久性。这种表集合和记录的概念,和ets表很类似。事实上,mnesia中的数据在节点内就是以ets表存储的。因此,mnesia实际上是一个分布式的ets。

2. 表的存储形式

mnesia中的表在节点内有三种存储形式:

  • ram_copies: 表仅存储于内存,可通过mnesia:dump_tables(TabList)来将数据导入到硬盘。
  • disc_copies: 表存储于内存中,但同时拥有磁盘备份,对表的写操作会分为两步:1.将写操作写入日志文件 2. 对内存中的表执行写操作
  • disc_only_copies: 表仅存储于磁盘中,对表的读写将会更慢,但是不会占用内存

表的存储形式可以在表的创建中指出,默认为ram_copies。也可以在创建表后通过mnesia:change_table_copy_type/3来修改。

阅读全文 »

C++继承了C的编译模型,而C是一门古老的语言,它的编译链接模型受限于当时的硬件条件限制,并且该模型也足够用于简洁的C。而C++继承了这些机制之后,引发了更为复杂的一些问题。

C 编译模型

首先简要介绍一下C的编译模型:

限于当时的硬件条件,C编译器不能够在内存里一次性地装载所有程序代码,而需要将代码分为多个源文件,并且分别编译。并且由于内存限制,编译器本身也不能太大,因此需要分为多个可执行文件,进行分阶段的编译。在早期一共包括7个可执行文件:cc(调用其它可执行文件),cpp(预处理器),c0(生成中间文件),c1(生成汇编文件),c2(优化,可选),as(汇编器,生成目标文件),ld(链接器)。

1. 隐式函数声明

为了在减少内存使用的情况下实现分离编译,C语言还支持”隐式函数声明”,即代码在使用前文未定义的函数时,编译器不会检查函数原型,编译器假定该函数存在并且被正确调用,还假定该函数返回int,并且为该函数生成汇编代码。此时唯一不确定的,只是该函数的函数地址。这由链接器来完成。如:

1
2
3
4
5
int main()
{
printf("ok\n");
return 0;
}

在gcc上会给出隐式函数声明的警告,但能编译运行通过。因为在链接时,链接器在libc中找到了printf符号的定义,并将其地址填到编译阶段留下的空白中。PS:用g++编译则会生成错误:use of undeclared identifier 'printf'。而如果使用的是未经定义的函数,如上面的printf函数改为print,得到的将是链接错误,而不是编译错误。

阅读全文 »

一. 定义

通常意义上,在C++中,可取地址,有名字的即为左值。不可取地址,没有名字的为右值。右值主要包括字面量,函数返回的临时变量值,表达式临时值等。右值引用即为对右值进行引用的类型,在C++98中的引用称为左值引用。

如有以下类和函数:

1
2
3
4
5
6
7
8
9
10
11
12

class A
{
private:
int* _p;
};

A ReturnValue()
{
return A();
}

ReturnValue()的返回值即为右值,它是一个不具名的临时变量。在C++98中,只有常量左值引用才能引用这个值。

1
2
3
4
5

A& a = ReturnValue(); // error: non-const lvalue reference to type 'A' cannot bind to a temporary of type 'A'

const A& a2 = ReturnValue(); // ok

通过常量左值引用,可以延长ReturnValue()返回值的生命周期,但是不能修改它。C++11的右值引用出场了:

A&& a3 = ReturnValue();

右值引用通过”&&”来声明, a3引用了ReturnValue()的返回值,延长了它的生命周期,并且可以对该临时值进行修改。

阅读全文 »

skynet提供一个gateserver用于处理网络事件,位于lualib/snax/gateserver.lua。云风在skynet wiki上介绍了gateserver的功能和使用范例。用户可以通过向gateserver提供一个自定义handle来向gateserver注册事件处理(玩家登录,断开,数据到达等)。

gateserver模块使用C编写的socketdriver和netpack模块,gateserver被加载时,会注册对”socket”(PTYPE_SOCKET)类型消息的处理,并且通过netpack.filter对收到的数据进行分包。分包完成后调用传入的handler对应的处理函数进行处理。它替上层使用者,完成对PTYPE_SOCKET消息的注册分发,以及消息分包。这样在使用时,只需提供一个handler,然后调用gateserver.start(handler)即可。

在skynet中,如果你要自定义你的gate网关服务gate.lua,需要执行以下几步:

  1. gateserver = require snax.gateserver
  2. gateserver.start(handler)向gateserver注册网络事件处理。
  3. skynet.call(gate, "lua", "open", conf)在外部向你定义的gate服务发送启动消息,并传入启动配置(端口,最大连接数等)来启动gate服务。
阅读全文 »

1. 异步IO

skynet用C编写的sokcet模块使用异步回调机制,通过lualib-src/lua-socket.c导出为socketdriver模块。skynet socket C API使用的异步回调方式是:在启动socket时,记录当前服务handle,之后该socket上面的消息(底层使用epoll机制)通过skynet消息的方式发往该服务。这里的当前服务指的是socket启动时所在的服务,对于被请求方来说,为调用socketdriver.start(id)的服务,对于请求方来说,为调用socketdriver.connect(addr,port)的服务。skynet不使用套接字fd在上层传播,因为在某些系统上fd的复用会导致上层遇到麻烦,skynet socket C API为每个fd分配一个ID,是自增不重复的。

阅读全文 »

前段时间关注到disruptor,一个高并发框架。能够在无锁(lock-free)的情况下处理多生产者消费者的并发问题。它可以看作一个消息队列,通过CAS而不是锁来处理并发。

因此实现了一个C++版本的disruptor,基于ring buffer,实现一个发送缓冲(多生产者,单消费者)。

写入缓冲

某个生产者要写入数据时,先申请所需空间(需要共享当前分配位置),然后直接执行写入,最后提交写入结果(需要共享当前写入位置)。整个写入过程由两个关键共享变量: atomic_ullong _alloc_countatomic_ullong _write_count。前者负责管理和同步当前分配的空间,后者负责同步当前已经写入的空间。也就是说,整个过程分为三步:申请,写入,提交。

比如,有两个生产者P1和P2。P1申请到大小为50的空间,假设此时_alloc_count=10,那么P1将得到可写入位置10,此时_alloc_count更新为60。P1此时可以执行写入(无需上锁)。这个时候P2开始申请大小为10的空间,它将得到写入位置60,_alloc_count更新为70。因此实际上P1和P2是可以并发写的。如果P2比P1先写完,它会尝试提交,此时由于P1还没有提交它的写入结果,因此P2会自旋等待(不断尝试CAS操作)。直到P1提交写入结果后,P2才能提交。通过CAS可以保证这种提交顺序。提交操作会更新_write_count变量,提交之后的数据便可以被消费者读取使用。

上面的描述并没有提到缓冲区不够的问题,为了判断缓冲区当前可写空间,还需要一个变量 atomic_ullong _idle_count用于记录当前缓冲区空闲大小。该变量在生产者申请空间后减小,在消费者使用数据后变大。初始等于整个ring buffer的大小。

阅读全文 »

协程基础

协程(协同式多线程)是一种用户级的非抢占式线程。”用户级”是指它的切换和调度由开发者控制,”非抢占”指一个协程只有在其挂起(yield)或者协程结束才会返回。协程和C线程一样,有自己的堆栈,自己的局部变量,自己的指令指针,并且和其它协程共享全局变量等信息。很多语言都有协程的概念,但在我看来,Python、JS、Lua这类语言的协程概念是类似的,C#有枚举器(迭代器),但没有协程(我在C#/Unity中的异步编程中有聊这个话题),Go语言中的goroutine也被翻译为协程,但实际上它是抢占式的轻量级线程,被称作协程(“协”本身就有协作协同之意)是有歧义的。在我的理解中,协程的本质就是用户级的控制权(执行权)的让出和恢复机制(以及相关的上下文保存和值传递机制),在理解这一点之后,其它如:

  • 协程是本质单线程的,协程可以实现单线程内的异步操作,并且无需考虑同步和加锁的问题
  • 在单线程内,同一时刻只有一个协程在运行
  • 协程可以以类似同步的方式来写异步代码
  • 协程可以让函数返回多次

等说法,也就比较好理解了。本文主要简单介绍下Lua协程。

阅读全文 »