Skip to content

高并发服务器:业务线程直接 send 为什么容易出问题? ​

在高并发网络服务中,常见的一种设计是:网络 IO 线程负责接收请求、业务线程负责处理请求、业务线程处理完成后,需要发送响应时,交由网络 IO 线程,由网络 IO 线程把响应发送给客户端。

IO 线程发送

还有一种设计是: 业务线程处理完成后直接把响应发送给客户端,无需发回网络 IO 线程。

小响应:业务线程直接发完;业务线程一次直接发送成功了,是一次快路径

大响应:如果一次无法发送完成,依然需要把剩余数据交由网络 IO 线程,由网络 IO 线程继续发送剩余数据,称之为慢路径。

work 线程发送

相比“业务线程把响应交回网络 IO 线程,再由网络 IO 线程统一发送”,它少了一次线程间投递,理论上可以减少响应延迟。

但它也带来了一系列的问题:同一个连接不再只由网络 IO 线程操作,业务线程也开始直接操作连接。

这会引出五个核心问题:

  • 客户端方向的,发送缓冲区被多线程同时读写;
  • 客户端收到的数据可能乱序;
  • fd 复用后,旧响应数据可能发给新连接;
  • 客户端已经关闭,业务线程仍然发送导致失败。
  • epoll 事件归属会混乱。到底由哪个线程操作 epoll,修改 epoll 事件呢?

下面按问题发生的顺序逐个分析。

一、发送缓冲区被多线程同时读写 ​

为什么连接需要发送缓冲区? ​

业务线程调用发送接口时,并不代表数据一定会一次性全部写入内核。

例如业务线程准备发送一个 100 KB 的响应,但此时内核发送缓冲区只剩 20 KB 空间,那么本次最多只能写入 20 KB,剩下 80 KB 必须暂存起来,等 socket 后续可写时继续发送。

这块暂存数据通常放在连接自己的发送缓冲区中。

在网络 IO 线程统一发送的模型中,发送缓冲区只由网络 IO 线程访问,所以不存在并发问题。

原本下面这些全部属于网络 IO 线程的职责,一旦业务线程也可以直接发送数据,那意味着这些原本属于网络 IO 线程的职责,现在存在多线程并发问题。

  • fd 属于网络 IO 线程
  • 读事件、写事件属于网络 IO 线程;
  • 连接关闭也由它处理;
  • 客户端响应的输出缓冲区也属于网络 IO 线程;
  • epoll 也属于网络 IO 线程;

竞争是怎么出现的? ​

业务线程可能在发送失败或只发送一部分时,把剩余数据放入连接发送缓冲区。

与此同时,网络 IO 线程可能因为收到了可写事件,正在从同一个发送缓冲区里取数据继续发送。

于是就变成:

  • 业务线程:向发送缓冲区追加数据
  • 网络 IO 线程:从发送缓冲区消费数据

这就是典型的多线程共享数据竞争。

多线程读写缓冲区

下面用一个时间线看清楚它的来龙去脉。

时间线具体内容
t1连接的发送缓冲区里已有 20 KB 数据,等待网络 IO 线程继续发送。
t2socket 变得可写,网络 IO 线程开始从发送缓冲区取数据。
t3业务线程处理完一个新请求,尝试直接发送响应。
t4业务线程发现缓冲区还有数据,于是准备把剩余数据追加到同一个发送缓冲区。
t5网络 IO 线程正在消费缓冲区,业务线程同时追加缓冲区。
t6如果没有同步保护,缓冲区的长度、读写位置可能被破坏。

发送缓冲区被多线程同时读写,它通常在高并发、慢客户端、大响应时一定会出现。

会造成什么后果? ​

发送缓冲区竞争可能导致很多现象:

  • 缓冲区的长度、读写位置被破坏;
  • 同一份数据,可能被重复发送;
  • 部分数据可能丢失,客户端无法收到,导致客户端无法解析
  • 发送顺序混乱,客户端无法解析;
  • 程序崩溃;

解决方案是什么? ​

核心原则是:发送缓冲区被多线程同时访问,必须进行同步,需要加锁

如果业务线程直接发送,并且业务线程在没发完时自己把剩余数据放入连接发送队列,那么就必须对发送队列进行加锁。

下面这些操作必须在同一把连接锁下完成:

  • 检查连接是否已经关闭,如果已经关闭,直接就不发送了
  • 检查发送缓冲区是否为空,也必须加锁,防止收到重复或者丢失数据。
  • 业务线程直接发送响应;
  • 业务线程把剩余数据追加到发送缓冲区;
  • 网络 IO 线程从发送缓冲区消费数据;
  • 网络 IO 线程关闭连接;
  • 一次没有发送完成,需要关注可写事件的标记。

共享数据加锁

为了减少锁的粒度,降低锁的开销。可以使用读写锁,自旋锁、或者使用无锁队列。

所以解决这个问题的关键不是“业务线程能不能 send”,而是:

业务线程直接发送数据、发送队列追加数据、网络 IO 线程消费数据、连接关闭,必须被同一个连接级别锁的保护下进行。

二、客户端收到的数据可能乱序 ​

什么叫数据乱序? ​

假设服务端要向同一个客户端发送两个响应:

响应 1:AAAA

响应 2:BBBB

客户端期望收到:

AAAABBBB

但实际可能收到:

AABBAABB

这就是数据乱序。乱序导致客户端无法解析出完整的内容。

注意,这不是 TCP 乱序。TCP 仍然保证字节按照写入内核的顺序到达客户端。

真正的问题是:服务端应用层把不同响应交错写进了同一个 TCP 字节流。

乱序是怎么发生的? ​

假设响应 1 没有一次发完。 响应 1 的前半部分已经写入内核,后半部分还在连接发送缓冲区里。

此时业务线程又生成了响应 2。如果业务线程没有检查发送缓冲区是否已有数据,而是直接发送响应 2,那么响应 2 可能插到响应 1 中间。

时间线具体内容
t1业务线程开始发送响应 1。
t2响应 1 只发送了一半,客户端已经收到 AA。
t3响应 1 剩余的 AA 被放入连接发送缓冲区。
t4业务线程生成响应 2。
t5业务线程没有检查发送缓冲区,直接发送响应 2。
t6客户端收到 BBBB。
t7网络 IO 线程继续发送缓冲区中的响应 1 剩余部分。
t8客户端又收到 AA。

最终客户端看到的是:

AA + BBBB + AA

这对应用层协议来说就是乱序。

数据乱序

解决方案是什么? ​

只要连接发送缓冲区不为空,业务线程就不能绕过它直接发送。

业务线程直接发送,只能在一个条件下发生:连接发送缓冲区为空。

如果连接发送缓冲区不为空,说明前面已经有数据没发完。此时新响应必须追加到发送缓冲区末尾,不能直接插队发送。

正确顺序应该是:

时间线具体内容
t1响应 1 前半部分已经写入内核。
t2响应 1 后半部分进入连接发送缓冲区。
t3业务线程生成响应 2。
t4业务线程发现发送缓冲区不为空。
t5业务线程把响应 2 追加到响应 1 后半部分之后。
t6网络 IO 线程后续按顺序发送:响应 1 后半部分,再发送响应 2。

这样客户端看到的就是: 完整响应 1 -> 完整响应 2

检查和发送必须是一个整体

还有一个容易忽略的点:业务线程不能只是“先检查一下发送缓冲区是否为空”,然后在没有同步保护的情况下再发送。

因为中间可能发生变化。

时间线具体内容
t1业务线程检查发送缓冲区为空。
t2另一个路径向发送缓冲区放入数据。
t3业务线程仍然直接发送。
t4新响应绕过了发送队列,顺序被破坏。

所以“检查发送缓冲区为空”和“直接发送”必须在同一把连接锁保护下完成。

因此最终的解决方案是:

  • 只要连接发送缓冲区不为空,业务线程就不能绕过它直接发送, 需要把数据插入到发送缓冲区末尾
  • 发送过程必须加锁

三、fd 复用后,旧响应可能发给新连接 ​

fd 复用到底是什么? ​

在 Linux 中,fd 只是进程内的一个整数。例如客户端 A 建立连接时,服务端可能得到: fd = 100

当客户端 A 断开后,服务端关闭这个 fd:close(100)

之后客户端 B 建立新连接,内核可能再次分配:fd = 100

这就是 fd 复用。fd 复用不是异常,它是操作系统的正常行为。高并发短连接场景下,fd 复用发生得非常快。

旧响应为什么会发给新连接? ​

问题通常发生在业务线程只保存了裸 fd 的情况下。

网络 IO 线程收到客户端 A 的请求后,把请求交给业务线程。业务线程开始处理。处理期间,客户端 A 断开,网络 IO 线程关闭了 fd。之后客户端 B 连接进来,内核复用了同一个 fd。

如果业务线程处理完 A 的请求后,仍然拿着原来的 fd 发送响应,就可能把 A 的响应发给 B。

时间线具体内容
t1客户端 A 建立连接,服务端得到 fd 100。
t2A 发送请求。
t3网络 IO 线程把 A 的请求交给业务线程。
t4业务线程开始处理 A 的请求。
t5A 断开连接。
t6网络 IO 线程关闭 fd 100。
t7客户端 B 建立连接。
t8操作系统复用 fd 100,把它分配给 B。
t9业务线程处理完 A 的请求。
t10业务线程继续向 fd 100 发送 A 的响应。
t11B 收到了本该属于 A 的响应。

这个问题非常严重,因为它不只是发送失败,而是数据发错对象。

fd 复用

根因是什么? ​

根因是:业务线程把 fd 当成了连接身份。

但 fd 不是连接身份。fd 只是内核分配的临时整数。

它不能表达:

  • 这个 fd 是否还属于原来的客户端;
  • 这个 fd 是否已经被关闭;
  • 这个 fd 是否已经被新连接复用;
  • 业务线程手里的响应是否还应该发送。

所以,高并发网络程序不能让业务线程长期持有裸 fd。

解决方案是什么? ​

解决这个问题的第一条规则是:

1、必须使用连接对象,而不是裸 fd。网络 IO 线程和业务线程通信时,不能只把裸 fd 交给业务线程,必须将整个连接对象指针交给业务线程,让业务线程也持有连接对象。

连接对象里至少要包含:

  • 当前 fd;
  • 连接状态:是否已经关闭,是否可以接收和发送等;
  • 发送缓冲区;
  • 连接锁;
  • 引用计数;
  • 所属网络 IO 线程。

当网络 IO 线程收到客户端关闭时,将连接状态设置为已经关闭。

业务线程需要发送数据时,会调用网络 IO 线程封装的 send 发送接口。接口里面根据连接对象进行如下判断:

  • 这个连接对象是否已经关闭,
  • 这个连接对象的 fd 是否已经失效;
  • 这个连接对象是否仍然允许写;
  • 连接发送缓冲区是否为空。
text
//发送时检查连接已经关闭,直接返回,不再发送
if (!conn->isConnected()) 
{
    return;
}

如果连接对象已经关闭,业务线程就丢弃响应,而不是继续向旧 fd 发送。

需要注意的是: 网络 IO 线程收到客户端关闭,将连接状态设置为已经关闭;以及业务线程发送数据时,检查连接状态是否已经关闭,也是需要同步的,需要在加锁保护下进行。

2、epoll_event 里必须放连接对象指针

为了避免旧事件和新连接混在一起,epoll 事件里不要只放 fd,而应该放连接对象指针。

因此,在将事件注册到 epoll 时,fd 关联的是一个 connection 指针。

epoll_event.data.ptr 存的是 connection 指针,不是 fd

text
struct epoll_event ev;
ev.events = EPOLLIN | EPOLLOUT;
ev.data.ptr = connection;
epoll_ctl(epollfd, EPOLL_CTL_ADD, fd, &ev);

epoll_wait 返回后:拿到的是当时注册进去的 connection 对象,而不是用 fd 再去查表。

struct connection * conn = events[i].data.ptr;

也就是说,事件返回时拿到的不是:fd 100; 而是连接对象 A。即使 fd 100 后来被客户端 B 复用,旧事件里携带的仍然是连接对象 A,而不会通过 fd 100 查到连接对象 B。

而如果维护的是一个 fd 到 connection 的 map,

类似 conn = fd_to_conn[100];

根据 fd 查连接表,就真可能发生旧连接的数据发送到新连接。

这就是为什么这里必须使用 epoll_event 携带连接对象指针。

3、连接对象不能立刻释放

使用连接对象指针后,还有一个重要要求:连接关闭后,连接对象不能马上释放。

考虑 2 种场景:

  • 客户端 close 后,网络 IO 线程收到 FIN 包,此时将 fd 关闭,并从 epoll 中将事件移除,同时将连接对象释放。但业务线程是不知道连接对象已经释放,后续业务线程使用连接对象继续发送数据时,使用的是一个已经释放的连接对象,引发 coredump

  • 网络 IO 线程中,epoll_wait 可能已经把事件返回到了用户态数组里。这个事件里还带着连接对象指针 struct connection * conn = events[i].data.ptr。网络 IO 线程还没来得及处理这个事件时,业务线程主动关闭 fd 并释放了连接对象。 之后网络 IO 线程处理这个事件时,使用这个连接指针,但此时连接对象已经释放了,也会引发程序 coredump;

两种场景下,如果连接关闭时,立即释放了连接对象,都将访问已经释放的内存。

怎么处理呢?

通常使用引用计数,连接对象的引用计数到 0 时,自动将连接对象释放。只要引用计数不为 0,连接对象就应该保持。

典型场景如下:

  • 网络 IO 线程创建连接对象的时候,引用计数初值设置为 1;
  • 网络 IO 线程与业务线程通信时,网络 IO 线程将连接对象交给业务线程,对引用计数加 1;延长连接对象的生命周期。防止网络 IO 线程提前释放了对象,而业务线程不知道。
  • 业务线程处理完成后,引用计数减 1,说明业务线程不会再使用连接对象了
  • 网络 IO 线程收到客户端关闭时,引用计数也减 1。
  • 引用计数为 0,就释放连接对象。

另外,关闭连接要分成两步:

  • 第一步:关闭 fd,标记连接对象已关闭,将事件从 epoll 中移除,将连接从 map 中移除。
  • 第二步:等确认没有线程和旧事件再使用这个连接对象后,也就是引用计数为 0 时,再释放连接对象。

四、客户端关闭后,业务线程仍然发送数据导致失败 ​

问题是怎么发生的? ​

网络 IO 线程收到请求后,会把请求交给业务线程。业务线程处理业务可能需要一段时间。在这段时间里,客户端可能已经断开。

fd 关闭后发送数据失败

我们看下这个问题的触发场景:

时间线具体内容
t1客户端发送请求。
t2网络 IO 线程读取请求。
t3网络 IO 线程把请求交给业务线程。
t4业务线程开始处理请求。
t5客户端超时或主动关闭连接。
t6网络 IO 线程感知到连接关闭。
t7业务线程处理完成,准备发送响应。

此时业务线程如果继续发送,就可能失败。

连接关闭时,应该怎么处理? ​

服务端 read 返回 0 后,不要立刻 close。服务端 read 返回 0,只表示对端已经关闭了发送方向,这个连接不会再有新的请求数据

从 TCP 报文看,客户端调用 shutdown(SHUT_WR) 和调用 close() 都可能发送 FIN,所以服务端不能仅凭 read 返回 0 判断客户端已经真的关闭了。

如果当前连接还有已解析请求,业务线程还正在处理,服务端可以尝试把响应发送出去。如果发送成功,就正常结束。如果发送失败,例如遇到 EPIPE、ECONNRESET 错误码,就说明连接已经不可写,丢弃响应并关闭连接。

连接关闭是业务线程直接发送时最容易出错的地方。

错误做法是:

text
网络 IO 线程 read 返回 0;
立刻关闭连接;
业务线程稍后发送响应失败。

正确做法是:

  • 网络 IO 线程给连接打上读关闭标记,但还可以写。此时先不关闭 fd。
  • 业务线程检查只是关闭了读端,还可以写。如果还有数据要发送,就直接发送。
  • 能发送成功,说明客户端只是半关闭
  • 发送失败,触发了 EPIPE、ECONNRESET 等错误码,说明客户端真的是执行了 close 或者发送了 RST。此时就要真的关闭了。

来看下具体的过程:

时间线具体内容
t1网络 IO 线程读到客户端请求。
t2网络 IO 线程把请求交给业务线程。
t3客户端关闭写方向。
t4网络 IO 线程 read 返回 0。
t5网络 IO 线程标记连接为读方向关闭。
t6网络 IO 线程将读事件从 epoll 中移除。
t7网络 IO 线程不关闭连接,因为还有业务请求未完成。
t8业务线程处理完成。
t9业务线程检查连接状态,发现只是读方向关闭,仍然允许写。
t10业务线程发送响应。
t11如果响应没发完,业务线程把剩余数据放入连接发送缓冲区。
t12网络 IO 线程后续通过可写事件继续发送。
t13响应全部发送完成。网络 IO 线程关闭连接

读方向关闭,不等于写方向关闭。所以业务线程发送前不能只看“读到 EOF”,而要看连接是否已经完全关闭。

如果业务线程发送失败,例如 EPIPE、ECONNRESET,就说明连接已经不可写,大概率是客户端 close 了。此时丢弃响应并关闭连接。

业务线程发送失败后,通常不直接 close,而是投递任务给网络 IO 线程,然后唤醒网络 IO 线程,交由网络 IO 线程统一进行 close

同理,如果业务线程自己主动要关闭,也是投递任务给网络 IO 线程,由网络 IO 线程进行关闭。

关闭流程

之所以要由网络 IO 线程来关闭,是因为每个连接的 socket fd,只属于网络 IO 线程。也就是说:

  • 读由网络线程处理;
  • 写由网络线程处理;
  • 连接关闭由它处理;
  • 输出缓冲区由它维护;
  • epoll 事件由它管理;

业务线程只处理业务逻辑,不直接碰 socket。

五、epoll 事件归属会混乱 ​

一个网络 IO 线程,负责一个 epoll 的事件处理。

fd 注册在某个网络 IO 线程的 epoll 上;

  • 这个线程负责读事件;
  • 这个线程负责写事件;
  • 这个线程负责修改 fd 的关注的读和写事件。

业务线程直接发送数据,如果一次性没有发送完成,将剩余数据保存到发送缓冲区。此时还需要调用 epoll_ctl 注册 EPOLLOUT 写事件。

那问题来了,谁负责调用 epoll_ctl 注册 EPOLLOUT 写事件。

epoll_ctl(epollFd, EPOLL_CTL_MOD, fd, ...);

如果由业务线程注册,这意味着业务线程要知道网络 IO 线程的 epoll fd,还要和网络 IO 线程并发修改同一个 fd 的事件状态,导致事件状态可能不符合预期。

并发注册写事件

例如:网络 IO 线程正在取消 EPOLLOUT;业务线程正在添加 EPOLLOUT;因为多线程竞争问题,导致网络 IO 线程取消 EPOLLOUT,覆盖了业务线程的添加 EPOLLOUT。

我们来看下触发这个问题的具体场景:

时间线具体内容
t1网络 IO 线程将发送队列中的数据全部发送完成。
t2网络 IO 线程准备将写事件从 epoll 移除,但还没来得及执行。
t3业务线程接着发送数据,一次没有全部发送完成
t4业务线程自己将写事件注册到 epoll。
t5网络 IO 线程继续执行,将写事件从 epoll 中移除。

因为多线程竞争问题,t5 中,网络 IO 线程将 t4 业务线程注册的写事件给移除了。导致写事件再也不会触发,数据一直存在发送缓冲区中。

解决方案是什么? ​

fd 的 epoll 事件修改,最好仍然由所属网络 IO 线程执行。

业务线程如果发现需要关注可写事件,不直接 epoll_ctl,而是投递一个任务给网络 IO 线程,唤醒网络 IO 线程,由网络 IO 线程注册可写事件。业务线程不能自己等待 socket 可写,等待可写这件事应该交给网络 IO 线程

但注意,这又产生了一次跨线程通知。所以业务线程直接发送的收益通常只在“小响应且一次写完”的场景里比较明显。

完整的处理流程是:

  • 业务线程可以尝试直接发送;
  • 如果一次写完,结束;
  • 如果没写完,把剩余数据挂到连接输出缓冲区;
  • 唤醒网络 IO 线程,由网络 IO 线程继续处理 EPOLLOUT。

IO 线程注册 epoll 事件

六、总结 ​

业务线程直接发送的初衷通常是:少一次跨线程通信,少一次唤醒;降低响应延迟。

这在响应内容比较小,send 基本一次成功场景下,确实能降低延迟。

典型场景是游戏领域。例如客户端出牌,客户端与服务器每次的消息都是非常短小的,一次 send 基本上都能发送完成。

如果追求极致低延迟,可以在业务线程直发 fast path。但必须有完整的锁、缓冲区、顺序、生命周期、和 epoll 事件归属等机制。否则这个优化很容易从“少一次队列”变成“多一堆并发事故”。

在复杂场景下,多线程之间需要加锁。为了省一次队列投递,引入了一套更复杂的连接并发控制。

因此对于大响应、大文件传输,慢客户端较多等场景,就不合适了。建议让网络 IO 线程统一发送

折中的方案是:

  • 业务线程直接发送数据,一次能够发送完成,是发送快路径。
  • 如果一次无法发送完成,也是要交由网络 IO 线程继续发送剩余数据,称之为慢路径。

我尽量站在读者的角度把来龙去脉讲清楚了,但每个人理解不一样。有任何疑问,可以在评论区里讨论,也可以联系作者。

如何联系作者

  • yudongtc 这个博客每周更新一篇高并发干货,图解方式讲解,需要可以浏览器访问。

  • 关注高并发成长记,加入 linux 网络编程交流群,一起讨论。

基于 VitePress 构建