网络编程IO复用之epoll

背景

在阻塞I/O下,一个线程只能处理一个流的I/O事件,这个流的处理卡住会阻塞整个线程。如果想同时处理多个流,要么多进程fork,要么多线程。很不巧两种方法效率都不高,这时I/O复用就诞生了。

首先定义流的概念,一个流可以是文件、socket、pipe等可以进行I/O操作的内核对象。所以不管你是文件,套接字还是管道我们都可以把他们看做流

要完成I/O复用要完成以下几件事情
1.用户态怎么将fd传递到内核态
2.内核态如何判断fd可读可写
3.内核怎么通知监控者fd可读可写
4.监控者这如何找到可读可写的fd并传递给用户态程序
5.继续循环时监控者这怎么重复上述步骤?

下面以fd来代表表示所有流

工作流程

一棵红黑树,一张准备就绪链表,少量内核cache就帮我们解决了大并发下socket处理问题。

  • 执行epoll_create时,内核在高速cache中创建了红黑树和就绪链表。
  • 执行epoll_ctl时,如果增加fd,则检查在红黑树中是否存在,存在立即返回,不存在则添加到树干上(用户态到内核态内存拷贝),然后向内核的中断处理程序注册回调,当红黑树中某个fd的中断事件来临时向就绪链表中插入数据(比如当一个socket上有数据到了,内核就把网卡上的数据copy到内核中然后把socket插入到就绪链表中)
  • 执行epoll_wait时只监控就绪链表。有数据立即返回(少量拷贝),没有数据就sleep等到timeout事件后即使没数据也返回。

伪代码

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
//epoll
while true {
active_stream[] = epoll_wait(epollfd)
for i in active_stream[] {
read or write till unavailable
}
}
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait(int epfd, struct epoll_event * events, int maxevents, int timeout);
struct epoll_event {
__uint32_t events;
epoll_data_t data;
};
解析:
epoll_create 创建一个epoll对象,一般epollfd = epoll_create()
epoll_ctl (epoll_add/epoll_del的合体),往epoll对象中增加/删除某一个流的某一个事件比如
epoll_ctl(epollfd, EPOLL_CTL_ADD, socket, EPOLLIN);//有缓冲区内有数据时(可读)
epoll_ctl(epollfd, EPOLL_CTL_DEL, socket, EPOLLOUT);//缓冲区可写入时epoll_wait返回
epoll_wait(epollfd,...)等待直到注册的事件发生
//select
while true {
select(streams[])
for i in streams[] {
if i has data
read until unavailable
}
}

红黑树查询复杂度,插入复杂度都是O(logN)

Epoll_wait行为见文档 linux-epoll_wait

水平触发(LT)和边缘触发(ET)

当一个socket句柄上有事件时,内核会把该句柄插入上面所说的准备就绪链表,这是我们调用epoll_wait,会把准备就绪的socket拷贝到用户态内存,然后清空准备就绪list链表,最后epoll_wait做了件事,就是检查这些socket,如果是LT模式,并且这些socket上有未处理的事件,就会把这些socket再次放回刚刚清空的就绪链表。所以,非ET的句柄,只要它上面还有事件,epoll_wait每次都会返回,而ET模式的句柄,除非有新中断到,即使socket上的事件没有处理完,也不会次次从epoll_wait返回。

php使用epoll

php本身无法直接使用epoll,不过我们可以借助libevent库来间接达到使用epoll的目的。libevent是一个高性能的网络事件库,内部实现了对select、poll、epoll、kqueue等的封装。
php想要使用libevent需要借助Event扩展

基于libevent实现定时器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 初始化一个EventConfig,虽然是个仅用于演示的空配置
$eventConfig = new EventConfig();
// 根据EventConfig初始化一个EventBase
$eventBase = new EventBase( $eventConfig );
// 初始化一个定时器event
$custom = [111];
$timer = new Event( $eventBase, -1, Event::TIMEOUT | Event::PERSIST, function($b) use( $custom ){
//echo microtime( true )." : 歼15,滑跃,起飞!".PHP_EOL;
print_r($b);
// print_r( $custom );
},[222]);
// tick间隔为0.05秒钟,我们还可以改成0.5秒钟甚至0.001秒,也就是毫秒级定时器
$tick = 0.05;
// 将定时器event添加(
$timer->add( $tick );
// eventBase进入loop状态
$eventBase->loop();

基于libevent实现的聊天室服务端,通过telnet 127.0.0.1:9999直接连接

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
36
37
38
39
40
41
42
43
44
45
46
<?php
$host = '0.0.0.0';
$port = 9999;
$fd = socket_create( AF_INET, SOCK_STREAM, SOL_TCP );
socket_set_option($fd, SOL_SOCKET, SO_REUSEADDR, 1);
socket_bind( $fd, $host, $port );
socket_listen( $fd );
// 注意,将“监听socket”设置为非阻塞模式
//socket_set_nonblock( $fd );
// 这里值得注意,我们声明两个数组用来保存 事件 和 连接socket
$event_arr = [];
$conn_arr = [];
echo PHP_EOL.PHP_EOL."欢迎来到ti-chat聊天室!发言注意遵守当地法律法规!".PHP_EOL;
echo " tcp://{$host}:{$port}".PHP_EOL;
$event_base = new EventBase();
$event = new Event( $event_base, $fd, Event::READ | Event::PERSIST, function() use ($fd) {
// 使用全局的event_arr 和 conn_arr
global $event_arr,$conn_arr,$event_base;
// 非阻塞模式下,注意accpet的写法会稍微特殊一些。如果不想这么写,请往前面添加@符号,不过不建议这种写法
if( ( $conn = socket_accept( $fd ) ) != false ){
echo date('Y-m-d H:i:s').':欢迎'.intval( $conn ).'来到聊天室'.PHP_EOL;
// 将连接socket也设置为非阻塞模式
// socket_set_nonblock( $conn );
// 此处值得注意,我们需要将连接socket保存到数组中去
$conn_arr[ intval( $conn ) ] = $conn;
//匿名函数第一个参数是监听的fd,第二个及之后的参数,是new Event时传入的参数
$event = new Event( $event_base, $conn, Event::READ | Event::PERSIST, function($t,$b) use($conn) {
global $conn_arr;
$buffer = socket_read( $conn, 65535 );
foreach( $conn_arr as $conn_key => $conn_item ){
if( $conn != $conn_item ){
$msg = intval( $conn ).'说 : '.$buffer;
socket_write( $conn_item, $msg, strlen( $msg ) );
}
}
},222);
$event->add();
// 此处值得注意,我们需要将事件本身存储到全局数组中,如果不保存,连接会话会丢失,也就是说服务端和客户端将无法保持持久会话
$event_arr[ intval( $conn ) ] = $event;
}else{
print(1111);
}
}, $fd );
$event->add();
$event_base->loop();

总结

本质上epoll提升性能的点在于

  1. 减少了文件描述符的拷贝次数
  2. 减少遍历文件描述符时检查的次数

参考

epoll原理
epoll相关基础知识
Linux内核epoll源码剖析
PHP socket初探 —- 一些零碎细节的拾漏补缺