之前曾经使用 epoll 构建过一个轻量级的 tcp 服务框架:
一个工业级、跨平台、轻量级的 tcp 网络服务框架:gevent
在调试的过程中,发现一些 epoll 之前没怎么注意到的特性。
a) iocp 是完全线程安全的,即同时可以有多个线程等待在 iocp 的完成队列上;
而 epoll 不行,同时只能有一个线程执行 epoll_wait 操作,因此这里需要做一点处理,
网上有人使用 condition_variable + mutex 实现 leader-follower 线程模型,但我只用了一个 mutex 就实现了,
当有事件发生了,leader 线程在执行事件处理器之前 unlock 这个 mutex,
就可以允许等待在这个 mutex 上的其它线程中的一个进入 epoll_wait 从而担任新的 leader。
(不知道多加一个 cv 有什么用,有明白原理的提示一下哈)
b) epoll 在加入、删除句柄时是可以跨线程的,而且这一操作是线程安全的。
之前一直以为 epoll 会像 select 一像,添加或删除一个句柄需要先通知 leader 从 epoll_wait 中醒来,
在重新 wait 之前通过 epoll_ctl 添加或删除对应的句柄。但是现在看完全可以在另一个线程中执行 epoll_ctl 操作
而不用担心多线程问题。这个在 man 手册页也有描述(man epoll_wait):
NOTES While one thread is blocked in a call to epoll_pwait(), it is possible for another thread to add a file descriptor to the waited-upon epoll instance. If the new file descriptor becomes ready, it will cause the epoll_wait() call to unblock. For a discussion of what may happen if a file descriptor in an epoll instance being monitored by epoll_wait() is closed in another thread, see select(2).
c) epoll 有两种事件触发方式,一种是默认的水平触发(LT)模式,即只要有可读的数据,就一直触发读事件;
还有一种是边缘触发(ET)模式,即只在没有数据到有数据之间触发一次,如果一次没有读完全部数据,
则也不会再次触发,除非所有数据被读完,且又有新的数据到来,才触发。使用 ET 模式的好处是,
不用在每次执行处理器前将句柄从 epoll 移除、在执行完之后再加入 epoll 中,
(如果不这样做的话,下一个进来的 leader 线程还会认为这个句柄可读,从而导致一个连接的数据被多个线程同时处理)
从而导致频繁的移除、添加句柄。好多网上的 epoll 例子也推荐这种方式。但是我在亲自验证后,发现使用 ET 模式有两个问题:
1)如果连接上来了大量数据,而每次只能读取部分(缓存区限制),则第 N 次读取的数据与第 N+1 次读取的数据,
有可能是两个线程中执行的,在读取时它们的顺序是可以保证的,但是当它们通知给用户时,第 N+1 次读取的数据
有可能在第 N 次读取的数据之前送达给应用层。这是因为线程的调度导致的,虽然第 N+1 次数据只有在第 N 次数据
读取完之后才可能产生,但是当第 N+1 次数据所在的线程可能先于第 N 次数据所在的线程被调度,上述场景就会产生。
这需要细心的设计读数据到给用户之间的流程,防止线程抢占(需要加一些保证顺序的锁);
2)当大量数据发送结束时,连接中断的通知(on_error)可能早于某些数据(on_read)到达,其实这个原理与上面类似,
就是客户端在所有数据发送完成后主动断开连接,而获取连接中断的线程可能先于末尾几个数据所在的线程被调度,
从而在应用层造成混乱(on_error 一般会删除事件处理器,但是 on_read 又需要它去做回调,好的情况会造成一些
数据丢失,不好的情况下直接崩溃)
鉴于以上两点,最后我还是使用了默认的 LT 触发模式,幸好有 b) 特性,我仅仅是增加了一些移除、添加的代码,
而且我不用在应用层加锁来保证数据的顺序性了。
d) 一定要捕捉 SIGPIPE 事件,因为当某些连接已经被客户端断开时,而服务端还在该连接上 send 应答包时:
第一次 send 会返回 ECONNRESET(104),再 send 会直接导致进程退出。如果捕捉该信号后,则第二次 send 会返回 EPIPE(32)。
这样可以避免一些莫名其妙的退出问题(我也是通过 gdb 挂上进程才发现是这个信号导致的)。
e) 当管理多个连接时,通常使用一种 map 结构来管理 socket 与其对应的数据结构(特别是回调对象:handler)。
但是不要使用 socket 句柄作为这个映射的 key,因为当一个连接中断而又有一个新的连接到来时,linux 上倾向于用最小的
fd 值为新的 socket 分配句柄,大部分情况下,它就是你刚刚 close 或客户端中断的句柄。这样一来很容易导致一些混乱的情况。
例如新的句柄插入失败(因为旧的虽然已经关闭但是还未来得及从 map 中移除)、旧句柄的清理工作无意间关闭了刚刚分配的
新连接(清理时 close 同样的 fd 导致新分配的连接中断)……而在 win32 上不存在这样的情况,这并不是因为 winsock 比 bsdsock 做的更好,
相同的, winsock 也存在新分配的句柄与之前刚关闭的句柄一样的场景(当大量客户端不停中断重连时);而是因为 iocp 基于提前
分配的内存块作为某个 IO 事件或连接的依据,而 map 的 key 大多也依据这些内存地址构建,所以一般不存在重复的情况(只要还在 map 中就不释放对应内存)。
经过观察,我发现在 linux 上,即使新的连接占据了旧的句柄值,它的端口往往也是不同的,所以这里使用了一个三元组作为 map 的 key:
{ fd, local_port, remote_port }
当 fd 相同时,local_port 与 remote_port 中至少有一个是不同的,从而可以区分新旧连接。
f) 如果连接中断或被对端主动关闭连接时,本端的 epoll 是可以检测到连接断开的,但是如果是自己 close 掉了 socket 句柄,则 epoll 检测不到连接已断开。
这个会导致客户端在不停断开重连过程中积累大量的未释放对象,时间长了有可能导致资源不足从而崩溃。
目前还没有找到产生这种现象的原因,Windows 上没有这种情况,有清楚这个现象原因的同学,不吝赐教啊
最后,再乱入一波 iocp 的特性:
iocp 在异步事件完成后,会通过完成端口完成通知,但在某些情况下,异步操作可以“立即完成”,
就是说虽然只是提交异步事件,但是也有可能这个操作直接完成了。这种情况下,可以直接处理得到的数据,相当于是同步调用。
但是我要说的是,千万不要直接处理数据,因为当你处理完之后,完成端口依旧会在之后进行通知,导致同一个数据被处理多次的情况。
所以最好的实践就是,不论是否立即完成,都交给完成端口去处理,保证数据的一次性。