printf函数一定是在read函数已经准备好数据之后才能执行,但read函数作为I/O操作通常需要等待而触发阻塞。调用read函数的是服务器进程,一旦被read调用阻塞,整个服务器在获取到用户数据前都不能接受任何其他用户的请求(单进程/线程)。
有了上面的基础,我们就可以介绍下面四种网路I/O模型。
阻塞式
阻塞表示一旦调用I/O函数必须等整个I/O完成才返回。正如上面提到的那种情形,当服务器调用了read函数之后,如果不是立即接收到数据,服务器进程会被阻塞,之后一直在等待用户数据到达,用户数据到达后首先会写进内核缓冲区,之后内核缓冲区数据复制到用户进程(服务器进程)缓冲区。完成了上述所有的工作后,才会把执行权限返回给用户(从内核态 -> 用户态)。
很显然,阻塞式I/O的效率实在太低,如果用户输入数据迟迟不到的话,整个服务器就会一直被阻塞(单进程/线程)。为了不影响服务器接收其他进程的连接,我们可以考虑多进程模型,这样当服务器建立连接后为连接的用户创建新线程,新线程即使是使用阻塞式I/O也仅仅是这一个线程被阻塞,不会影响服务器等待接收新的连接。
多线程模型下,主线程等待用户请求,用户有请求到达时创建新线程。新线程负责具体的工作,即使是因为调用了read函数被阻塞也不会影响服务器。我们还可以进一步优化创建连接池和线程池以减小频繁调用I/O接口的开销。但新问题随之产生,每个新线程或者进程(加入使用对进程模型)都会占用大量系统资源,除此之外过多的线程和进程在调度方面开销也会大很对,所以这种模型并不适合大并发量。
非阻塞I/O
阻塞和非阻塞最大的区别在于调用I/O系统调用后,是等整个I/O过程完成再把操作权限返回给用户还是会立即返回。
可以使用以下语句将句柄fd设置为非阻塞I/O:fcntl(fd, F_SETFL, O_NONBLOCK);
非阻塞I/O在调用后会立即返回,用户进程对返回的返回值判断以区分是否完成了I/O。如果返回大于0表示完成了数据读取,返回值即读取的字节数;返回0表示连接已经正常断开;返回-1表示错误,接下来用户进程会不停地询问kernel是否准备完毕。
非阻塞I/O虽然不再会完全阻塞用户进程,但实际上由于用户进程需要不停地询问kernel是否准备完数据,所以整体效率依旧非常低,不适合做并发。
I/O多路复用(事件驱动模型)
前面已经论述了多进程、多进程模型会因为开销巨大和调度困难而导致并不能承受高并发量。但不适用这种模型的话,无论是阻塞还是非阻塞方式都会导致整个服务器停滞。
所以对于大并发量,我们需要一种代理模型可以帮助我们集中去管理所有的socket连接,一旦某个socket数据到达了就执行其对应的用户进程,I/O多路复用就是这么一种模型。Linux下I/O多路复用的系统调用有select,poll和epoll,但从本质上来讲他们都是同步I/O范畴。
select
相关接口:
int select (int maxfd, fd_set readfds, fd_set writefds, fd_set errorfds, struct timeval timeout);
FD_ZERO(int fd, fd_set* fds) //清空集合
FD_SET(int fd, fd_set* fds) //将给定的描述符加入集合
FD_ISSET(int fd, fd_set* fds) //将给定的描述符从文件中删除
FD_CLR(int fd, fd_set* fds) //判断指定描述符是否在集合中
参数:
maxfd:当前最大文件描述符的值+1(≠ MAX_CONN)。
readfds:指向读文件队列集合(fd_set)的指针。
writefds:同上,指向读集合的指针。
writefds:同上,指向错误集合的指针。
timeout:指向timeval结构指针,用于设置超时。
其他:
判断和操作对象为set_fd集合,集合大小为单个进程可打开的最大文件数1024或2048(可重新编译内核修改但不建议)。
poll
相关接口:
int poll(struct pollfd *fds, unsigned int nfds, int timeout);
结构体定义:
struct pollfd{
int fd; // 文件描述符
short events; // 等到的事件
short revents; // 实际发生的事件
}
参数:
fds:指向pollfd结构体数组的指针。
nfds:pollfd数组当前已被使用的最大下标。
timeout:等待毫秒数。
其他:
判断和操作对象是元素为pollfd类型的数组,数组大小自己设定,即为最大连接数。
epoll