阻塞IO
服务端为了处理客户端的连接和请求的数据,写了如下代码。
listenfd = socket(); // 打开一个网络通信端口bind(listenfd); // 绑定listen(listenfd); // 监听while(1) {connfd = accept(listenfd); // 阻塞建立连接int n = read(connfd, buf); // 阻塞读数据doSomeThing(buf); // 利用读到的数据做些什么close(connfd); // 关闭连接,循环等待下一个连接}
这段代码会执行得磕磕绊绊,就像这样。

服务端的线程阻塞在了两个地方,一个是 accept 函数,一个是 read 函数。
如果再把 read 函数的细节展开,我们会发现其阻塞在了两个阶段。
整体流程如图:
如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 read 函数上不返回,也无法接受其他客户端连接。
改造函数,每一次创建新的连接都创建一个新的进程,去调用read函数,并作相应处理。
while(1) {connfd = accept(listenfd); // 阻塞建立连接pthread_create(doWork); // 创建一个新的线程}void doWork() {int n = read(connfd, buf); // 阻塞读数据doSomeThing(buf); // 利用读到的数据做些什么close(connfd); // 关闭连接,循环等待下一个连接}
- 这样客户端建立好连接之后,就可以立即等待新客户的连接,不需要阻塞在原客户端的read请求上。
 

- 这并不是非阻塞IO,只是用了多线程使得没有让主线程卡在read函数上而已,read函数依然是阻塞的。
 
如果要实现真正的非阻塞IO,我们需要操作系统提供一个非阻塞的read函数:
- 如果没有数据到达,read立即返回-1,而不是被阻塞
- 操作系统提供了这样的功能,只需要在调用 read 前,将文件描述符设置为非阻塞即可。
这样,用户线程循环调用read,直到返回不为-1,在开始处理业务。fcntl(connfd, F_SETFL, O_NONBLOCK);int n = read(connfd, buffer) != SUCCESS);

 
 - 操作系统提供了这样的功能,只需要在调用 read 前,将文件描述符设置为非阻塞即可。
 
注意!
这分为两个阶段:
- 在数据没有拷贝到内核缓冲区之前,是非阻塞的。
 - 拷贝到内核缓冲区之后,read依旧是阻塞的
 

IO多路复用
为每个客户端创建一个线程,服务器资源消耗太快。
我们可以每accept一个客户端连接后,将文件描述符放到一个数组里面。
fdlist.add(connfd);
然后让一个新线程不断去遍历这个数组,调用每一个元素的非阻塞read方法。
while(1) {for(fd : fdlist) {if(read(fd) != -1) {doSomeThing();}}}
这样就可以让一个线程处理多个客户端连接。
但是!
这依旧是个小把戏,每次遍历遇到read返回-1依然是系统调用,很浪费资源。
可以让os提供一个:将一批文件描述符通过一次系统调用传给内核,由内核层去遍历(以至于不会又内核用户态切换的开销)
select
select是一个系统调用,通过它,可以把一个文件描述符数组发给os,让os在内核态去进行遍历,确定哪些文件描述符可以读写,然后告诉我们去处理。

- 服务器一个线程不断接收新的连接请求,并把socket文件描述符放到一个list里面。
 

- 另一个线程调用select,让os去遍历socket列表
 

- 不过,当 select 函数返回后,用户依然需要遍历刚刚提交给操作系统的 list。
- 只不过,操作系统会将准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销。
while(1) {nready = select(list);// 用户层依然要遍历,只不过少了很多无效的系统调用for(fd : fdlist) {if(fd != -1) {// 只读已就绪的文件描述符read(fd, buf);// 总共只有 nready 个已就绪描述符,不用过多遍历if(--nready == 0) break;}}}

 
 - 只不过,操作系统会将准备就绪的文件描述符做上标识,用户层将不会再有无意义的系统调用开销。
 
总结
- select调用需要传入标识符数组拷贝到内核,资源消耗巨大(可以优化为不复制)
 - select在内核层依然通过便利的方式检查文件描述符的就绪状态,是一个同步的过程,只不过无系统调用上下文切换的开销。(可优化为异步事件通知)
 - select仅仅返回可读文件描述符的个数,不返回具体哪个文件描述符可用,还需要用户自己遍历。(可以优化为返回具体的可用的文件描述符)
 

多个文件描述符只有一次 select 的系统调用 + n 次就绪状态的文件描述符的 read 系统调用。
poll
它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。
epoll
epoll 是最终的大 boss,它解决了 select 和 poll 的一些问题。

- 内核保存了一份文件描述符列表,无须用户每次重新传入,只需要告诉内核修改的部分就可。
 - 内核不再以轮询方式找到就绪的文件描述符,而是通过异步IO事件唤醒。
 - 内核仅将IO事件的文件描述符返回给用户。
 
具体,操作系统提供了这三个函数。
第一步,创建一个 epoll 句柄
int epoll_create(int size);
第二步,向内核添加、修改或删除要监控的文件描述符。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
第三步,类似发起了 select() 调用
int epoll_wait(int epfd, struct epoll_event *events, int max events, int timeout);
使用起来,其内部原理就像如下一般丝滑。

