阻塞IO模型中,用户进程在内核等待网卡数据和内核数据拷贝到用户缓冲区两个阶段都处于等待状态。
在非阻塞IO模式下,用户应用执行recvfrom系统调用命令操作时会立即返回结果而不是阻塞用户进程等待命令执行完毕。虽然非阻塞IO不会让用户进程进入阻塞状态,但是性能也并不会很高,因为它会让该进程进入忙等状态,不断地占用CPU去调用recvfrom命令,导致CPU使用率暴增。同时,第二阶段的从内核缓冲区中拷贝数据到用户缓冲区依旧会阻塞等待。
无论是阻塞IO还是非阻塞IO,用户应用在一阶段都需要调用recvfrom来获取数据,差别在于无数据时的处理方案:
但是在单线程情况下,一个线程同时只能处理一个socket请求。当这个socket请求处于等待数据就绪的状态下,所有其它客户端的socket都必须等待,这种服务器的性能自然就很差。
提高效率的方法有哪些?
方案一: 增加多个线程,服务端一共可以有多少个线程,那么就可以同时处理多少个客户端socket请求。
方案二: socket不排队,哪个数据就绪,服务端就去处理哪个数据对应的socket请求。
文件描述符(File Descriptor): 简称FD,是一个从0开始递增的无符号整数,用来 关联 Linux中的一个文件。在Linux中,一切皆文件,例如常规文件、视频、硬件设备等,当然也包括网络套接字(Socket)。
而IO多路复用就是利用 单个线程 去同时监听多个FD(Socket请求),当某个FD可读、可写的时候这个线程就会得到通知,然后去进行后续的处理,这样可以避免浪费CPU资源。
监听FD的方式、通知的方式又有多种实现,常见的有:select、poll、epoll。
// 定义类型别名 __fd_mask,本质是 long int
typedef long int __fd_mask;
/* fd_set 记录要监听的fd集合,及其对应状态 */
typedef struct {
// fds_bits是long类型数组,长度为 1024 / 32 = 32
// long int 是32位的,这个数组一共32个元素,每个元素32个bit,所以一共1024个bit位,每个bit位代表一个fd,0代表未就绪,1代表就绪。可以监听1024个fd。
__fd_mask fds_bits[__FD_SETSIZE / __NFDBITS];
} fd_set;
// select函数,用于监听多个fd的集合
int select(
int nfds, // 要监视的fd_set的最大fd + 1
fd_set *readfds, // 要监听读事件的fd集合
fd_set *writefds, // 要监听写事件的fd集合
fd_set *exceptfds, // 要监听异常事件的fd集合
struct timeval *timeout // 超时时间,null-永不超时;0-不阻塞等待;大于0-固定等待时间
);
1.调用 select()
用户空间传入三个 fd_set(可读、可写、异常)和超时时间 timeout。这些 fd_set 会被拷贝到内核空间(因为内核需要监控这些 fd)。
2.内核等待就绪事件
内核会遍历 fd_set 里的所有文件描述符,注册到等待队列中(网络情况下就是 socket 对应的等待队列)。如果所有描述符当前都没就绪,当前线程会阻塞,直到:
3.就绪唤醒
当某个 fd 的底层设备(例如 socket)在内核中被标记为可读/可写/异常时,内核会唤醒 select() 阻塞的线程。此时,内核会再次遍历全部 fd,找出哪些真的就绪(因为可能唤醒时多个 fd 同时可用)。
4.结果拷贝回用户空间
内核会把已就绪的 fd 信息更新到传入的 fd_set 中,并把这个结构拷贝回用户空间。
注意:返回值是就绪的 fd 数量,而不是具体 fd。
5.用户空间遍历
用户程序在自己的 fd_set 中遍历,找出已就绪的 fd,然后调用 read() 或 write() 去处理数据。
select存在的问题:
poll模式对select模式做了改进,但是性能提升不是很明显,
#include <sys/poll.h> // 标准头文件,实际使用时应包含
// pollfd 中的事件类型
#define POLLIN 0x001 // 可读事件
#define POLLPRI 0x002 // 高优先级数据可读
#define POLLOUT 0x004 // 可写事件
#define POLLERR 0x008 // 错误事件
#define POLLHUP 0x010 // 挂起事件
#define POLLNVAL 0x020 // 文件描述符未打开
// pollfd结构
struct pollfd {
int fd; /* 要监听的文件描述符 */
short int events; /* 要监听的事件类型(输入参数) */
short int revents; /* 实际发生的事件类型(输出参数) */
};
// poll函数声明
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
IO流程:
比select改进的点:1.定义了pollfd结构,不需要传多个fd数组了,可以根据pollfd结构本身判断该fd应该监听的是什么事件。2.fd集合在内核中采用链表,理论上无上限了。
但是这些改进提升都很小,尤其是fd集合无上限这种情况,会导致遍历消耗的事件很长,性能反而下降。
epoll对select和poll做了很大的改进。 它提供了三个函数,分别是 int epoll_create , int epoll_ctl , int epoll_wait 。
struct eventpoll {
//...
struct rb_root rbr; // 一颗红黑树,记录要监听的FD
struct list_head rdlist;// 一个链表,记录就绪的FD
//...
};
// 1.会在内核创建eventpoll结构体,返回对应的句柄epfd
int epoll_create(int size);
// 2.将一个FD添加到epoll的红黑树中,并设置ep_poll_callback
// callback触发时,就把对应的FD加入到rdlist这个就绪列表中
int epoll_ctl(
int epfd, // epoll实例的句柄
int op, // 要执行的操作,包括:ADD、MOD、DEL
int fd, // 要监听的FD
struct epoll_event *event // 要监听的事件类型:读、写、异常等
);
// 3.检查rdlist列表是否为空,不为空则返回就绪的FD的数量
int epoll_wait(
int epfd, // eventpoll实例的句柄
struct epoll_event *events, // 空event数组,用于接收就绪的FD
int maxevents, // events数组的最大长度
int timeout // 超时时间,-1用不超时;0不阻塞;大于0为阻塞时间
);
select和poll模式下,我们会不断地把需要监听的fd数组拷贝到内核空间,然后再把监听到了的fd数组拷贝回用户空间,很浪费事件,而在epoll模式下,得益于 events 数据结构以及在内核空间中创建的eventpoll结构体,每个事件触发的fd都会被直接放到events中,不再像select和poll模式中每次拷贝都是整个fd数组一起拷贝,减少了拷贝的数量。使用 epoll_ctl 方法也是添加一个fd到需要监听的列表中,不再需要反复传递和拷贝fd数组了。并且,select和poll模式下,监听到就绪fd之后,返回的是数量,还需要遍历所有的fd,而epoll模式下就不需要遍历,因为 events 中的所有fd都是就绪的。
select 模式存在的三个问题:
poll 模式的问题:
epoll 模式中如何解决这些问题的?
信号驱动IO 是与内核建立SIGIO的信号关联并设置回调,当内核有FD就绪时,就会发出SISIO信号通知用户,期间用户应用可以执行其它业务,无需阻塞等待。注意这里和 非阻塞IO 有点相同,就是都会在第一阶段不阻塞,第二阶段读取数据的时候还是会阻塞。但是非阻塞IO是不断轮询,而这里会释放该线程去做别的事情。
缺点: 当有大量IO操作的时候,信号较多,SIGIO处理函数不能及时处理可能会导致信号队列溢出。而且内核空间和用户空间的频繁信号交互性能也较低。
异步IO 的整个过程都是非阻塞的,用户进程调用完异步API后就可以去做其他事情,内核等待数据就绪并且拷贝到用户空间后才会递交信号,通知用户进程。
异步IO的时候用户进程是不会阻塞的,可以一直处理请求,但是用户进程把任务的控制都交给了内核,如果交给内核的请求太多,那么内核就会太忙了。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。