1. epoll
1.1 常见的网络模式
以ipv4中tcp协议编程为例:
- 首先创建一个socket套接字,即用于监听的文件描述符listen_fd,
- 将它与具体的ip和端口号绑定,
- 开启监听,
- 使用一个循环来接受客户端的请求,
- 创建子进程或者线程来处理已经连接的请求
//创建监听的文件描述符 listen_fd = socket() //绑定ip和端口 bind(listen_fd, ip和端口) //监听 listen(listen_fd) //循环处理链接和读写操作 while(1) { //主进程用来接收连接 new_client_fd = accept() //创建子进程或线程处理,处理新的客户端的请求 }
缺点
这种模式的问题在于创建子进程、线程都有系统调用,每来一个新的TCP连接都需要分配一个进程或者线程,如果达到C10K,意味着一台机器要维护1万个进程/线程,应对高并发的场景存在一定的性能问题。
能不能让一个进程/线程来维护多个socket呢?当然,就是I/O多路复用技术。
1.2 epoll网络模式
一个进程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1 毫秒以内,这样 1 秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程,这就是多路复用,这种思想很类似一个 CPU 并发多个进程,所以也叫做时分多路复用。我们熟悉的 select/poll/epoll 内核提供给用户态的多路复用系统调用,进程可以通过一个系统调用函数从内核中获取多个事件。
对比select/poll/epoll 的文章很多,这里不再阐述。因为epoll在性能方面相比select、poll存在很大的优势,所以我们直接来看epoll编程。epoll相关的函数只有3个:
//创建epoll的句柄 int epoll_create(int __size) //将普通的网络文件描述符添加到epoll描述符中 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)
- epoll_create是创建一个epoll的描述符epoll_fd
- epoll_ctl函数将epoll_fd ((int __epfd) 和 socket_fd (int __fd) ,添加 EPOLL_CTL_ADD (int __op) 或删除 EPOLL_CTL_DEL (int __op) 到epoll反应堆中,最后一个参数struct epoll_event *__event 是一个结构体,里边有2个参数需要设置:①设置触发模式ev.events = EPOLLIN | EPOLLET; ,epoll的触发模式包括边缘触发和水平触发 ②设置socket对应的fd:ev.data.fd = listen_fd;
- epoll_wait是获取触发的事件,第1个参数为epoll_fd, 第2个参数用于接收触发了事件的数组,后续处理就是遍历这个数组,第3个参数为可以处理的事件的最大值,第4个参数为等待时间,-1表示阻塞等待,0表示立即返回不等待,大于0的值为等待的时间。
epoll编程示例
//创建监听的文件描述符 listen_fd = socket() //绑定ip和端口 bind(listen_fd, ip和端口) //监听 listen(listen_fd) //创建epoll句柄 epoll_fd = epoll_create(MAXEPOLLSIZE); //将监听的listen_fd添加到epoll中 //创建 ev 变量,在epoll_ctl函数中使用 struct epoll_event ev; //设置触发模式 ev.events = EPOLLIN | EPOLLET; //设置fd变量 ev.data.fd = listen_fd; //将listen_fd添加到epoll集合中 epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &ev) //将监听的listen_fd添加到epoll中 //创建一个数组,用于接受所有触发的读写事件 struct epoll_event fired_events[MAXEPOLLSIZE]; //循环处理链接和读写操作 while(1) { //等待有事件发生,fired_events中存储已经触发的事件,-1表示没有超时时间,返回触发的事件数量 epoll_event_nums = epoll_wait(epoll_fd, fired_events, curfds, -1); for(j = 0; j < epoll_event_nums; j++) { if(fired_events[j].data.fd == listen_fd) { //如果触发事件的描述符是 listen_fd) //1.执行 accept()函数 new_client_fd = accept(listen_fd, xx, xx) //2.将新的客户端连接fd添加到epoll集合中 ev.events = EPOLLIN | EPOLLET; ev.data.fd = new_client_fd; epoll_ctl(epoll_fd, EPOLL_CTL_ADD, new_client_fd, &ev) } else { //如果是已连接的客户端触发的事件,则进行读写操作 if(fired_events[i].events&EPOLLIN) {//如果是已经连接的用户,并且收到数据,那么进行读入。 //如果是读事件 recv(fired_events[j].data.fd, buf, xx, xx) } if(fired_events[i].events&EPOLLOUT) {如果有数据发送 //如果是写事件 send() } } } }
名词解释:
- OS将I/O状态的变化都封装成了事件,如可读事件、可写事件,对应代码就是:EPOLL_CTL_ADD注册事件、EPOLL_CTL_MOD修改事件、EPOLL_CTL_DEL删除事件
- events几个状态:EPOLLIN:表示对应的文件描述符可以读(包括对端SOCKET正常关闭);EPOLLOUT:表示对应的文件描述符可以写;
2. CallBack
通过上面epoll示例我们可以看到把I/O事件的等待和监听任务交给了操作系统内核,内核在I/O状态发生改变后(例如socket连接已建立成功可发送数据),即发生了可读可写事件后(EPOLLIN/EPOLLOUT),回调我们注册的函数(recv/send),这样我们就收到了内核的通知完成收发数据操作。
Python标准库提供的selectors模块是对底层epoll等的封装。DefaultSelector类会根据内核环境自动选择最佳的模块,那在 Linux2.5.44及更新的版本上都是epoll了。
selectors调用register注册在某个socket fd上的事件和事件回调函数,这就相当于调用epoll中epoll_ctl方法。详细对照看epool示例。
3. Event loop
那如何从selectors里获取当前正发生的事件,并且得到对应的回调函数去执行呢?
为了解决上述问题,我们参照上述epoll模式,写一个循环,去访问selectors模块中的select方法,等待它告诉我们当前是哪个事件发生了,应该对应哪个回调。这个等待事件通知的循环,称之为事件循环。
def loop(): while True: events = sel.select() for key, mask in events: callback = key.data callback(key.fileobj, mask)
请详细看上面epoll示例的while循环逻辑
selector.select() 是一个阻塞调用,因为如果事件不发生,那应用程序就没事件可处理,所以就干脆阻塞在这里等待事件发生。那可以推断,比如只下载一篇网页,一定要connect()之后才能send()继而recv(),那它的效率和阻塞的方式是一样的。因为不在connect()/recv()上阻塞,也得在select()上阻塞。
所以,selectors机制是用来解决大量并发连接的。当系统中有大量非阻塞调用,能随时产生事件的时候,selectors机制才能发挥最大的威力。
4. 小结
epoll网络模式+Callback回调+Event loop事件循环机制,这三者构成了异步编程的三驾马车,所有异步编程核心都几乎离不开它们,所以了解其原理与概念对学习其他语言的异步编程有很大的帮助。