epoll在多线程中的应用-EPOLLEXCLUSIVE和REUSEPORT(一)

以下均为对epoll多线程中的使用的一些笔记,如果有不对的地方,烦请指出

主要对于我所遇到的问题进行讨论,不会讨论代码如何改写,探讨如何解决这个问题

一.引言

这些问题均是我在编写我的Web服务器遇到的,我在编写多线程Web服务器的时候,思考如何利用多核的优势来编写Web服务器.在学习了muduo网络库之后,我的先前一个版本的Web服务器采用这种方式,一个master线程+多个工人线程,但是我觉得在高并发的情况下只有一个线程可以accept这无疑限制了accept吞吐量,并不算利用多核优势.进而引发了我对于在多线程中如何高效合理的使用epoll有了探索.

二.epoll file descriptor 和 kernel file description 生命周期的问题

`谈及这个问题,我们需要了解

  • (1) 进程维护file descriptor表 ,每个fd包含
  • fd 标志
  • 指向内核的file descriptor表象的指针
  • (2)内核维护所有打开文件的file description表,每一个表都包含文件的状态标志
  • 当前文件的offest
  • 文件状态标志(读,写,阻塞,非阻塞)
  • 指向该文件v节点

每个进程的task_struct 包含了用于完全应该工作的成员

struct task_struct {
	//文件系统信息
	int link_count , total_link_count;
	---
	struct files_struct * files; //打开的文件信息
	---
}

struct files_struct {

	-----
	atomic_t count; //引用技术
}

图片来源
上述图片来源

  • 在用户态使用close() 减少一次count , 当count 为0,才会从内核的file description 删除

  • 实际上,epoll( ) 主要是混淆了用户态的file descriptor和内核态中真正用于实现的 file description . 当进程调用close 关闭fd ,就会出现问题,也就是说我们在调用的时候,传入的是用户态的 file descriptor ,也就是平时我们所用的fd那个数字,但是在内核中,引用的是file description ,就是那个内核对象

  • epoll_ctl(EPOLL_CTL_ADD) 实现上并不是注册一个file descriptor(fd) , 而是将fd 和一个指向内核file description 的指针一块注册给了epoll ,也就是说epoll中管理的fd 生命周期,是内核中的相对应的file descriptor

我们可以查看最权威的man手册

Q6 Will closing a file descriptor cause it to be removed from all epoll sets automatically?
A6 Yes, but be aware of the following point. A file descriptor is a reference to an open file
description (see open(2)). Whenever a descriptor is duplicated via dup(2), dup2(2), fcntl(2)
F_DUPFD, or fork(2), a new file descriptor referring to the same open file description is created.
An open file description continues to exist until all file descriptors referring to it have been
closed. A file descriptor is removed from an epoll set only after all the file descriptors refer-
ring to the underlying open file description have been closed (or before if the descriptor is
explicitly removed using epoll_ctl() EPOLL_CTL_DEL). This means that even after a file descriptor
that is part of an epoll set has been closed, events may be reported for that file descriptor if
other file descriptors referring to the same underlying file description remain open.

同时也就是说当我们使用close去关闭掉一个fd 的时候,如果这个fd 是内核中file description的唯一引用
,内核中的 file description 同时也会跟着一起删除,这样没有什么问题,但是如果有其他引用,close并不会删除这个file descrption. 这样epoll会继续上报这个已经close掉的fd 上的事件,并且此时上次的应用程序看到的现象是关闭了fd ,但是 epoll_wait 会不断返回关闭的fd的事件信息,更为头疼的是fd已经被关闭了,我们无法通过epoll_ctl去从集合中删除掉这个fd

从Marek的博客中的代码中得到

rfd, wfd = pipe()
write(wfd, "a")             # Make the "rfd" readable
epfd = epoll_create()
epoll_ctl(efpd, EPOLL_CTL_ADD, rfd, (EPOLLIN, rfd))

rfd2 = dup(rfd)
close(rfd)

r = epoll_wait(epfd, -1ms)  # still recv event!!!

所以我们应该在close之前首先调用epoll_ct在epoll中删除掉相应的fd

三.特定的TCP listen fd 的accept 的问题

这个问题是我思索很久的问题,如果我的web服务器在某种场景上,需要应对大量的短连接,那么我会想如何把accpet()分发到不同的CPU 上,来利用多核的能力.

1)首先我想到的方案是所有线程共用一个isten fd每一个线程创建epollfd ,把listen fd加入到所有线程的epoll 中.

  • 一开始发现这种情况会引发惊群问题,如果有新的连接,会唤醒所有的线程,但是只有一个线程可以accpet成功,其他线程会进行失败
  • 但是我发现这种形式效率并不低,按道理来说惊群应该会降低效率,但是为什么会增加效率?
  • 经过查询资料和自己的测试,可以这么理解,如果说线程是一堆小鸡的话,在饲料不多的情况下,是需要一些小鸡进行休息的,但是在饲料很多的情况下,如何让饲料更快的消耗?当然是让所有小鸡同时进行饲料的竞争

2)这样说的话,将所有的线程共用一个epollfd也会引起惊群问题

  • 在尝试这个方法的同时,我想到了边缘触发会不会更好处理这个问题?
  • 但是并没有,我们可以来看看下面这个例子
  1. 内核:收到第一个连接请求。线程 A 和 线程 B 两个线程都在 epoll_wait() 上等待。由于采用边缘触发模式,所以只有一个线程会收到通知。这里假定线程 A 收到通知
  2. 线程A:epoll_wait() 返回
  3. 线程A:调用 accpet() 并且成功
  4. 内核:此时 accept queue 为空,所以将边缘触发的 socket 的状态从可读置成不可读
  5. 内核:收到第二个建连请求
  6. 内核:此时,由于线程 A 还在执行 accept() 处理,只剩下线程 B 在等待 epoll_wait(),于是唤醒线程 B
  7. 线程A:继续执行 accept() 直到返回 EAGAIN
    .8. 线程B:执行 accept(),并返回 EAGAIN,此时线程 B 可能有点困惑(“明明通知我有事件,结果却返回 EAGAIN”)
    .9. 线程A:再次执行 accept(),这次终于返回 EAGAIN
  • 当然也有可能会造成线程饥饿的问题,我们来看看下面这个例子
  1. 内核:接收到两个建连请求。线程 A 和 线程 B 两个线程都在等在 epoll_wait()。由于采用边缘触发模式,只有一个线程会被唤醒,我们这里假定线程 A 先被唤醒
  2. 线程A:epoll_wait() 返回
  3. 线程A:调用 accpet() 并且成功
  4. 内核:收到第三个建连请求。由于线程 A 还没有处理完(没有返回 EAGAIN),当前 socket 还处于可读的状态,由于是边缘触发模式,所有不会产生新的事件
  5. 线程A:继续执行 accept() 希望返回 EAGAIN 再进入 epoll_wait() 等待,然而它又 accept() 成功并处理了一个新连接
  6. 内核:又收到了第四个建连请求
  7. 线程A:又继续执行 accept(),结果又返回成功

线程A很忙但是线程B没有活干

3)说这多,那什么是正确的做法?

从epoll的角度来考虑的话,有两种做法

  • 最好也是唯一支持的是从内核4.5+开始新增的水平触发模式的EPOLLEXCLUSIVE标志,这个标志会保证一个事件只有一个epoll_wait()会被唤醒,避免了"惊群效应",并且可以完美的在多个CPU之间进行扩展
  • 那么EPOLLEXCLUSIVE为什么可以做到这一点? 有这个标志位的fd,会唤醒线程中的空闲队列的头一个
  • 当然内核不够4.5怎么办?我们可以通过ET下的EPOLLONESHOT 来模拟 LT+ EPOLLEXCLUSIVE的效果
    这样是有一定的代价的,需要在每次事件处理完成之后额外多调用一次 epoll_ctl(EPOLL_CTL_MOD) 重置这个 fd。这样做可以将负载均分到不同的 CPU 上,但是同一时刻,只能有一个 worker 调用 accept(2)。显然,这样又限制了处理 accept(2) 的吞吐。

4)当然我们也有其他方案(SO_REUSEPORT)

  • 我们可以使用SO_REUSEPORT这个socket option ,创建多个listen socket 共用一个端口号
  • SO_REUSEPORT在TCP连接中是通过内核来选取套接字来进行接受连接
  • SO_REUSEPORT有两种模式,热备份和负载均衡模式,当然在3.9之后,全部是负载均衡模式
  • SO_REUSEPORT当有连接到来时,用数据包的源IP/源端口作为一个HASH函数的输入,将结果对reuseport套接字数量取模,得到一个索引,该索引指示的数组位置对应的套接字便是工作套接字。
  • 但是也有弊端,就是一个listen socket  fd 被关,被分到这个listen socket fd 的accept队列上的东西会被丢弃掉
  • SO_REUSEPORT的好处:解决了epoll惊群问题,程序有了更好的扩展性,只有在同一个用户下相同的服务器进程才能监听同一ip:port (安全性考虑)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章