Socket缓冲区过小触发TCP Nagle's algorithm算法导致网络延迟大

在游戏服务器当中,通常都会为每个客户端链接设置一个缓冲区。这样做的理由是游戏中通常会有持续不断,零碎的数据包发送到客户端,使用一个缓冲区可以把这些数据包攒到一起发送,避免频繁的io操作;另一个原因是,处理游戏逻辑的线程通常和io操作的线程是分开的,因此游戏逻辑线程把数据放到缓冲区后可以继续处理后续的逻辑,数据的收发交给io线程。

我自己设计的服务器,在早期的版本中,Socket的缓冲区是采用了一个大小可变缓冲区。即每个Socket创建时,需要上层逻辑根据Socket的类型指定缓冲区的上限,例如客户端Socket上限为64kb,服务器与服务器之间的上限为64M。然后给socket初始化一个8kb的缓冲区,当缓冲区满并且未达到上限时,通过memcpy切换到一个更大的缓冲区,如16kb,接着是32kb,64kb...,只增不减,直到达到上限。这样设计的优点是收(recv)、发(send),还是(通过protobuf)解析数据包,,由于永远只有一个缓冲区,数据在内存上是连续的,那这些操作都可以一次完成。缺点也十分明显,这缓冲区是可变的,意味着我需要设计一个大小可变的内存池,类似于boost的ordered_malloc和ordered_free,并且由于只增不减,浪费的内存也相对严重。

在后续版本的优化当中,我认为针对游戏服务器而言,一个健康的服务器socket的缓冲区中不应该有太多的数据。即可以在Socket中缓存64M的数据,但对于游戏服务器而言,单个请求数据量往往很小,64M数据意味着上百万个请求被阻塞,这服务器已经卡到可以关服了,没什么意义了。因此我决定改掉这个复杂又浪费内存单个缓冲区设计,转而采用典型的链表结构,即缓冲区1-->缓冲区2-->缓冲区3-->null。第一个缓冲区满,再申请一个同样大小新的缓冲区链到链表尾部,当缓冲区用完时,依次释放到内存池。这种设计缓冲区的大小是固定的,设计简单可靠,通过调整单个缓冲区的大小,可以大大地提高缓冲区利用率。而随之而来的缺点是由于数据在内存上不是连续的,收(recv)、发(send)只能分多次进行,(通过protobuf)解析数据包时,由于数据不连续,需要把数据拷贝到一个足够大,连续的缓冲区才能进行解析。不过这些缺点只要单个缓冲区的大小配置得当,都是极少出现的,因此是可以接受的。

优化完成后,重新进行了测试,测试的方式也很简单

客户端每秒发送一个ping数据包(里面包含一个巨大的随机字符串) --- 服务器网关进程收到数据包 --- 网关进程向其他进程(AREA1、AREA2、WORLD)发起ping                                                                                                          |                                                                                                          |                                                                                                          ▼ 客户端收到ping返回,校验字符串是否完整,并校验延迟是否在预期范围 <<<---  服务器网关进程返回数据包  <<<---  网关进程收到其他进程(AREA1、AREA2、WORLD)返回的ping数据包,并记录延迟 

测试结果表示字符串是完整的,说明缓冲区的设计基本没有问题。但发现了一个令我十分不解的问题,那就是延迟实在是太大了。

[A1LP05-06 22:03:28]ping	android_65537	29	1	42225 [A1LP05-06 22:03:30]ping	android_65537	30	85	19149 [A1LP05-06 22:03:30]     latency too large	AREA(I2.S1)	85 [A1LP05-06 22:03:30]     latency too large	AREA(I1.S1)	85 [A1LP05-06 22:03:30]     latency too large	WORLD(I1.S1)	85 [A1LP05-06 22:03:30]ping	android_65537	31	0	47831 [A1LP05-06 22:03:31]ping	android_65537	32	42	8181 [A1LP05-06 22:03:31]     latency too large	AREA(I2.S1)	42 [A1LP05-06 22:03:31]     latency too large	AREA(I1.S1)	42 [A1LP05-06 22:03:31]     latency too large	WORLD(I1.S1)	42 

从日志上看ping android_65537 29 1 42225表示机器人android_65537,ping的序列为29,延迟为1ms,用于校验的字符串长度为42225byte。可以看到这个延迟十分的不稳定,有时候很小(<3ms),有时候很大(>80ms),有时候中等(~=40ms)。我一度以为程序哪里出了问题,比如是不是出现了锁竞争,epoll_wait是不是传错参数了,线程的唤醒是不是不及时。经过一番排查,没有发现明显的错误,进行一些尝试后发现几个特点:

  1. 使用大缓冲区后,延迟消失
    我把单个缓冲区改得很大(80kb,远超过ping包的大小),延迟变得正常,大约只有1~2ms的延迟。尝试客户端使用大缓冲区,服务器使用小缓冲区,或者反过来,服务器用大缓冲区,服务器使用小缓冲区,延迟小的概率稍微变大,但出现不正常延迟的情况还是居多

  2. 使用std::clock函数统计不到延迟(<1ms),用std::chrono::steady_clock则可以

  3. windows 10下执行,没有延迟。linux下(debian 10,跑在VirtualBox虚拟机)有延迟
    排除WSAPollepoll差异的原因,怀疑是VirtualBox的网络有问题,但查不到相关资料

继续排查,分别把客户端和服务器epoll_wait和send、recv时的std::chrono::system_clock打印出来。由于客户端和服务器运行在同一个系统,因此它们打印的system_clock时间是可以对比的。对比时间后发现,客户端send成功后,有时候服务器的epoll_wait需要40ms左右才会被唤醒;同样的服务器send成功后,客户端也有概率延迟唤醒。查了一堆资料,终于查到这是TCP触发了Nagle's algorithm算法,这个延迟是一个正常的延迟。

简单来讲,Nagle's algorithm算法是一个用于提高tcp发送小包效率的算法。即多次往tcp发送少量数据时(多次调用::send)函数时,tcp会把要发送的数据缓存起来,攒成一个大的数据包,等超时(一般是40ms)或者收到对端的tcp ack再真正发送出去。具体的算法较为复杂,这里不细说,参考其他资料即可。

而我设计的这两个版本的收发操作分别为:

旧版本,单一的大缓冲区

// 发送 int bytes = ::send(fd, buffer.data, buffer.len);  // 接收 int bytes = ::recv(fd, buffer.data, buffer.len); 

新版本,链表结构的多个小缓冲区

// 发送 while (true) {     size_t len = 0;     const char *data = buffer.next(len);     if (!data) break; // 整个链表发送完成      int bytes = ::send(fd, data, len);      // ... 异常及返回值处理 }  // 接收 while (true) {     size_t len = 0;     const char *data = buffer.reserve(len);     if (!data) break;      int bytes = ::recv(fd, data, len);      // ... 异常及返回值处理 } 

在旧版本中,只有一个send操作,因此不会触发这个算法。而在新版本中,需要多次调用send函数,那tcp的Nagle's algorithm算法会认为还有后续数据要发送,因此一直在攒数据。但这个算法它实际上是无法知道具体的业务逻辑到底还有没有数据要发送的,所以它只能等到超时发送,这样延迟就来了。要解决这个问题,关闭Nagle's algorithm算法即可,关闭的方式就是设置TCP_NODELAY这个标识。

int32_t Socket::set_nodelay(int32_t fd) {     /**      * 默认是不开启NODELAY选项的,在Nagle's algorithm算法下,tcp可能会缓存数据包大约      * 40ms,如果双方都未启用NODELAY,那么数据一来一回可能会有80ms的延迟      */ #ifdef CONF_TCP_NODELAY     #ifdef __windows__     DWORD optval = 1;     return setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, (char *)&optval,                       sizeof(optval));     #else     int optval = 1;     return setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, (void *)&optval,                       sizeof(optval));     #endif #else     return 0; #endif } 

启用TCP_NODELAY后,延迟基本都在1ms以下,属于正常范围了

[A1LP05-06 23:07:35]ping	android_65537	1	1	27556 [A1LP05-06 23:07:36]ping	android_65537	2	1	57353 [A1LP05-06 23:07:37]ping	android_65537	3	0	10809 [A1LP05-06 23:07:38]ping	android_65537	4	0	10786 [A1LP05-06 23:07:39]ping	android_65537	5	1	38473 [A1LP05-06 23:07:40]ping	android_65537	6	1	47318 [A1LP05-06 23:07:41]ping	android_65537	7	0	5877 [A1LP05-06 23:07:42]ping	android_65537	8	1	38044 [A1LP05-06 23:07:43]ping	android_65537	9	1	50385 [A1LP05-06 23:07:44]ping	android_65537	10	1	60623 

现在回到我的程序中,几个疑点都有了答案:

  1. 延迟十分的不稳定,有时候很小(<3ms),有时候很大(>80ms),有时候中等(~=40ms)
    由于ping数据的大小是随机的,因此有时候没有触发Nagle's algorithm算法,所以会出现没有延迟的地方。假如客户端发送数据、服务器返回数据都触发了这个算法,那么延迟大约就是40 + 40 = 80ms左右。如果只有一端触发,那就是40ms左右

  2. 使用大缓冲区后,延迟消失
    大缓冲区不会触发这个算法,所以没有延迟

  3. 使用std::clock函数统计不到延迟(<1ms),用std::chrono::steady_clock则可以
    std::clock函数统计的是当前进程CPU运行的时间,它不包括epoll_wait、sleep等时间,而std::chrono::steady_clock是统计当前过了多少时间

  4. windows 10下执行,没有延迟
    这个我并没有解决,可能是win下默认开启了TCP_NODEALY,也可能是默认启用TCP_QUICKACK,但我并没有查到资料,这个后面再继续查了。