Recording

Commit volatile memory to persistent append-only log

0%

最近尝试了几款 C++ 编译系统。

  • CMake 对于一个编译系统来说,个人觉得过于复杂了。项目依赖不好解决,可能我姿势不对,用不好 ExternalProject 。

  • CPM 基于 CMake ,对项目结构以及名字空间有额外要求。我写我的代码,导出我的接口,你还想管我怎么写?

  • biicode 自动生成了很多隐式配置,用的时候很大可能要修改这些配置,作者脑洞有点大。不推荐。

  • meson 很轻量的一个编译系统,配置语言很好用。依赖可以在系统软件包和源码之间切换。对现有项目提供的 patch 支持很不错,不需要对上游做破坏性更新。文档还是比较少的,有些东西估计得读下代码才知道怎么用。值得一试。

  • bazel Google 出品,离 1.0 还很远,不过已经可以用了。很合适 Google 那种集中式的代码库,根目录一个 WORKSPACE ,其他的项目都只是一个 BUILD ,项目间的依赖也很好指定。和系统软件包的配合不好,连 make install 都没有。patch 支持,一个 BUILD 文件就可以搞定,复杂的话,可能比较麻烦。文档很不错。推荐。

这几款编译系统都没能解决一个问题:编译时第三方依赖的源码和软件包之间的一致性。举例来说:

如果 packageA 的头文件 header.hpp 通过 make install 或其他的包管理工具安装在 packageA/header.hpp ,那么 packageB 一定可以通过 #include <packageA/header.hpp> 引用到这个文件,不论 packageB 依赖的是 packageA 的源码还是系统软件包。header.hpp 可以在 packageA 的任意位置,甚至可能是编译时生成的头文件。

要做到这一点,我的想法是:在编译 packageB 时,如果依赖的是 packageA 的源码,则先编译 packageA,将其安装在私有的目录,之后通过修改编译参数指定头文件包含目录和链接目录。如果依赖的是 packageA 的软件包,则直接用系统的头文件包含目录和链接目录。这样的话,就可以通过配置语言 packageA 的源码和软件包之间切换。

meson 可以在依赖的源码和软件包之间切换,但是需要根据源码结构和包结构之间的差异做特别的定制。bazel 就是一集中化的代码仓库,包含路径只能相对根目录和当前目录。

也许得自己撸一个?Damnit!

Read more »

FreeBSD 下的 kqueue 监听的单位是 (ident, filter) , Linux 下的 epoll 监听的单位是单个 fd 。在 Linux 下,通常你需要对 epoll 监听的 fd 做一些额外的记录工作,以便下次更改时查询。这里直接用 Redis 的代码做个示例。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// FreeBSD kqueue
static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
aeApiState *state = eventLoop->apidata;
struct kevent ke;

if (mask & AE_READABLE) {
EV_SET(&ke, fd, EVFILT_READ, EV_ADD, 0, 0, NULL);
if (kevent(state->kqfd, &ke, 1, NULL, 0, NULL) == -1) return -1;
}
if (mask & AE_WRITABLE) {
EV_SET(&ke, fd, EVFILT_WRITE, EV_ADD, 0, 0, NULL);
if (kevent(state->kqfd, &ke, 1, NULL, 0, NULL) == -1) return -1;
}
return 0;
}

static void aeApiDelEvent(aeEventLoop *eventLoop, int fd, int mask) {
aeApiState *state = eventLoop->apidata;
struct kevent ke;

if (mask & AE_READABLE) {
EV_SET(&ke, fd, EVFILT_READ, EV_DELETE, 0, 0, NULL);
kevent(state->kqfd, &ke, 1, NULL, 0, NULL);
}
if (mask & AE_WRITABLE) {
EV_SET(&ke, fd, EVFILT_WRITE, EV_DELETE, 0, 0, NULL);
kevent(state->kqfd, &ke, 1, NULL, 0, NULL);
}
}

从上面的代码可以看到,FreeBSD kqueue 在改变 fd 的监听事件时不需要做额外的记录工作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// Linux epoll
static int aeApiAddEvent(aeEventLoop *eventLoop, int fd, int mask) {
aeApiState *state = eventLoop->apidata;
struct epoll_event ee = {0}; /* avoid valgrind warning */
/* If the fd was already monitored for some event, we need a MOD
* operation. Otherwise we need an ADD operation. */
int op = eventLoop->events[fd].mask == AE_NONE ?
EPOLL_CTL_ADD : EPOLL_CTL_MOD;

ee.events = 0;
mask |= eventLoop->events[fd].mask; /* Merge old events */
if (mask & AE_READABLE) ee.events |= EPOLLIN;
if (mask & AE_WRITABLE) ee.events |= EPOLLOUT;
ee.data.fd = fd;
if (epoll_ctl(state->epfd,op,fd,&ee) == -1) return -1;
return 0;
}

static void aeApiDelEvent(aeEventLoop *eventLoop, int fd, int delmask) {
aeApiState *state = eventLoop->apidata;
struct epoll_event ee = {0}; /* avoid valgrind warning */
int mask = eventLoop->events[fd].mask & (~delmask);

ee.events = 0;
if (mask & AE_READABLE) ee.events |= EPOLLIN;
if (mask & AE_WRITABLE) ee.events |= EPOLLOUT;
ee.data.fd = fd;
if (mask != AE_NONE) {
epoll_ctl(state->epfd,EPOLL_CTL_MOD,fd,&ee);
} else {
/* Note, Kernel < 2.6.9 requires a non null event pointer even for
* EPOLL_CTL_DEL. */
epoll_ctl(state->epfd,EPOLL_CTL_DEL,fd,&ee);
}
}

从上面的代码可以看到,Linux epoll 在改变 fd 的监听事件时需要查询之前监听的事件,记录当前监听的事件。
这样 API 的调用者就无需做额外的记录工作,但读写事件需要额外的同步机制去保证线程安全。

Q3 Is the epoll file descriptor itself poll/epoll/selectable?

A3 Yes. If an epoll file descriptor has events waiting, then it will indicate as being readable.

---- epoll(7)

下面的代码利用了 epoll fd 本身在有等待事件时是可读的特性,展示了一种新的在 Linux epoll 下对 fd 读写
事件进行监听的方法。该方法分离了 fd 的读和写,这样我们就可以把 fd 的读和写交给不同的线程去处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
// initialization
int epfd = epoll_create(1);
int readfd = epoll_create(1);
int writefd = epoll_create(1);

struct epoll_event event;
event.events = EPOLLIN;

event.data.fd = readfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, readfd, &event);

event.data.fd = writefd;
epoll_ctl(epfd, EPOLL_CTL_ADD, writefd, &event);
1
2
3
4
5
// wait read event
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLIN; // with possible other flags.
epoll_ctl(readfd, EPOLL_CTL_ADD, fd, &ev);
1
2
3
4
5
6
// wait write event
struct epoll_event ev;
ev.data.fd = fd;
ev.events = EPOLLOUT; // with possible other flags.
// No bookkeeping needed for read flags.
epoll_ctl(writefd, EPOLL_CTL_ADD, fd, &ev);
Read more »

游戏开发、运营过程中通常会有 “给全服所有玩家发送邮件” 的需求。

我个人的想法是,给每封全服邮件一个版本号,玩家上线时或定期向 “全服邮件中心” 查询更新的全服邮件。下面是 C++ 版本的示例代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Global Mail Centra
using version_t = uint64;
using mail_id_t = uint64;

std::map<version_t, mail_id_t> _mails;

std::tuple<version_t, std::vector<mail_id_t>> fetchNewerMail(version_t currentVersion) {
auto newerVersion = currentVersion;
std::vector<mail_id_t> newerMails;
for (auto it = _mails.upper_bound(currentVersion); it != _mails.end(); ++it) {
newerVersion = it->first;
newerMails.push_back(it->second);
}
return std::make_tuple(newerVersion, newerMails);
}


// Global Mail Proxy in Player Module
version_t _currentVersion;

std::vector<mail_id_t> checkGlobalMails() {
version_t newerVersion;
std::vector<mail_id_t> newerMails;
std::tie(newerVersion, newerMails) = fetchNewerMails(_currentVersion);
if (newerVersion != _currentVersion) {
_currentVersion = newerVersion;
// update _currentVersion to backend data storage
}
return newerMails;
}

如果服务器框架有很好的 coroutine 支持,玩家模块可以用一个独立的 coroutine 不停的向 “全服邮件中心” 获取邮件更新;“全服邮件中心” 根据自身状态决定立即或延迟回复以恢复对方的执行流。例如,在 skynet 中,可以这样做:

1
2
3
4
5
6
7
8
9
10
11
12
13
function update_global_mails()
local currentVersion = get_current_version()
while true do
newerVersion, newerMails = skynet.call(".global_mail_centra", "lua", "fetchNewerMails", currentVersion)
if newerVersion != currentVersion then
// synchronized to player's mailbox (other module in same process), blocking call
currentVersion = newerVersion
// update currentVersion to backend data storage
end
end
end

skynet.fork(update_global_mails)

推荐阅读:skynet 中如何实现邮件达到通知服务

Read more »

参与并理解策划需求

能把事情做好的人永远是少数。程序如此,策划也如此。遇到不靠谱的策划,一定好主动沟通,弄清楚它们都在想些什么?!

隔离是必须的

游戏逻辑相关开发要占用游戏服务器开发的绝大部分时间,也是出现 bug 最多的部分。游戏逻辑的各个子系统之间直接的源码级 API 交互,会随着团队成员和子系统的增加,呈现出急剧的复杂度。更好的方案是,子系统通过唯一的中间层 API 以约定的协议与其他子系统进行交互,子系统之间实现源码级的隔离。

这种分案有很多优点:

  • 便于模块分配,降低了源码冲突的可能性,限制了单个子系统 bug 的波及范围。
  • 降低了系统的整体复杂度,便于人类的大脑理解。
  • 子系统的实现变更(重构、重写)的代价(负担)会很小,代码质量会越来越好。
  • 容易定位造成 bug 的子系统。
  • 心情舒坦,利于长寿

代码质量

代码质量是一件严肃的事情。所有改 (zao) 善 (ta) 代码质量的行为,都会获得回 (bao) 报 (ying) 的。

潜在即是必然

一个发生概率为百万分之一的 bug ,在线上运行时是必然会发生的

Read more »

在很早的时候,我就接触到 Algorithms + Data Structures = Programs 这样的概念。

在几年的工作之后的现今,我不认为这个等式可以表达所有的程序,也许只是我所工作的领域无法让我体会到这个等式的价值。

下面是我尝试去总结自己的经历,得到的另一个等式。

模块 + 模块间的通信 = 程序
modules + communications between modules = program

这里的模块不应该狭义的理解为 C/C++ 语言当中的模块化概念,我把 C/C++ 语言当中的模块称之为“源码级模块”。“源码级模块”通过函数调用的方式进行通信,彼此之间是紧耦合的,在多人协作的软件开发活动中会加剧开发人员之间的沟通成本,也没法很好的适应需求变更和扩展。

与“源码级模块”对应的是“运行时模块”,其模块本身有着对应的运行时的实体表示,对外提供服务。“运行时模块”可以表现为服务器内部对其他模块提供服务的运行时实体,也可以表现为提供运行时服务的服务器。很少有语言层面的“运行时模块”的直接支持, Erlang 是其中之一。 Erlang 中的 process 就是一个运行时的实体,我将之视为“运行时模块”。在没有提供“运行时模块”支持的语言中,可以通过将整个程序分层,由底层提供“运行时模块”的抽象。相对于“源码级模块”,“运行时模块”具有天然的强隔离性和可扩展性。这两点可以很好的应对软件开发活动中的多人协作和需求变更。

“运行时模块”通过消息传递的方式进行通信,跨节点的支持需要对消息体进行序列化和反序列化。 Erlang 中的消息可以跨节点进行传输,

对在 C/C++ 中引入“运行时模块”的一个潜在担忧是可能会导致程序性能的下降,我的想法是承认性能的损失,为此换来的是编程模型的提升。

在软件开发中引入的大多数“抽象”和“分层”(如果不是全部)都会引起性能的损失,但是今天的大部分程序仍然是运行在“进程”和“虚拟地址空间”的抽象之上。

Read more »