阻塞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);
使用起来,其内部原理就像如下一般丝滑。