文章目录
在看本文章前一定要看参考文献,甚至可以不看本文直接看参考文献
参考文献:https://blog.csdn.net/weixin_46778443/article/details/124889138
网络IO操作
阻塞式IO操作
非阻塞IO操作
IO多路复用
epoll举例:
1、****主线程会阻塞在epoll_wait()调用上,直到有一个或多个文件描述符变为就绪状态;(节省CPU资源,线程在没有事件发生时不会占用CPU时间)。
2、一但有一个或多个文件描述符变为就绪状态(有数据可读或可写),epoll_wait()就返回(从内核态到用户态),主线程就会被唤醒,处理这些就绪的文件描述符;
3、主线程处理这些就绪的文件描述符,例如读取数据、写入数据或者处理连接等;
4、处理完所有就绪事件后,主线程再次调用epoll_wait(),进入阻塞状态,等待下一个事件的发生;
2、工作线程通常不会阻塞在epoll_wait上,有以下几种情况:
1、等待任务
工作线程在等待任务队列中的任务时,可能会因为任务队列为空而阻塞。常见的实现方式是使用条件变量和互斥锁来同步任务队列的访问。
2、处理任务
工作线程从任务队列中获取任务后,会进行实际的数据读写操作。此时,工作线程不会阻塞,除非在处理I/O时遇到阻塞的系统调用(如阻塞式的read或write)。
针对本项目:主线程负责监听和分派事件,工作线程从任务队列中取出任务进行处理;其实任务队列就是就绪的文件描述符;工作线程拿到这个文件描述符后对文件描述符进行IO操作;
信号驱动式IO
注意一个重点:要将套接字和进程进行绑定(属主设置),这是为了让内核在该套接字上发生I/O事件时,将SIGIO发送到该套接字对应的进程中;
异步IO
总结
Reactor和Proactor模式
简单来说:Reacor模式就是将连接描述符conn_fd加入请求队列,请求队列中存放都是文件描述符;主线程没有读取;(通常由同步IO实现)
Proactor模式是将通过文件描述符读取的数据加入请求队列,请求队列中存放的都是读取的数据,主线程已经读取完毕了(通常用异步IO实现)
服务器处理流程
服务器启动
|
V
主线程阻塞在epoll_wait()上,工作线程阻塞在条件变量上
|
客户端连接
|
V
主线程通过监听套接字检测到事件 -> 接受连接 -> 注册conn_fd到epoll
|
V
主线程再次阻塞在epoll_wait()上
|
客户端发送数据
|
V
epoll_wait()返回 -> 主线程检测到数据事件 -> 将conn_fd加入任务队列 -> 通知工作线程
|在这里插入代码片
工作线程竞争锁 -> 成功获取锁的工作线程处理任务 -> 处理完成后再次阻塞在条件变量上
并发编程 半同步/半异步模式
同步线程指的是在执行某个任务时,线程会等待该任务完成,之后才继续执行后续的代码。这意味着线程在等待任务完成期间会被阻塞,不能执行其他任务。
异步线程指的是在执行某个任务时,线程不会等待该任务完成,而是可以继续执行其他任务。当异步任务完成时,会通过回调、事件或通知机制告知线程任务已经完成。
半同步/半异步模式
这里的异步说的是:异步线程监听到客户请求后,就将其封装成请求对象并插入请求队列中。(主线程干的活)
但实际上这里的异步实现是这样的:
epooll_wait()这个函数本身是同步阻塞的,它等待事件的发生;
epoll_wait()函数后续的执行也是同步的,因为就是按顺序对事件进行插入到请求队列活这建立新的客户端链接;
那为什么还要说这里是异步的呢?
因为epoll提供了一个异步事件通知机制:
1、通过epoll可以注册多个文件描述符,并对这些文件描述符进行非阻塞IO操作;
2、epoll内部的事件通知机制并非是轮询每个文件描述符的状态(不会有I/O的等待切换),而是通过操作系统在后台监视;
说白了,就是epoll监控多个IO,一次性可以返回多个文件描述符发生的事件,这里就是异步;
项目中:
1、异步:主线程监听客户端请求,请其封装成请求插入到请求队列(每一个请求其实就是文件描述符,或者通过文件描述符读取的数据等)
2、同步:工作线程从请求队列中读取,在同步模式下处理该请求;
IO多路复用中的EPOLLONESHOT 事件
一个线程读取某个Socket上的数据后开始处理数据,在处理过程中该Socket上又有新数据可读,另一个线程被唤醒读取,此时出现两个线程处理同一个Socket。我们期望一个Socket连接在任一时刻都只被一个线程处理,
通过对该文件描述符注册 EPOLLONESHOT 事件,令一个线程处理socket时,其他线程将无法处理。当该线程处理完后,需要重置 EPOLLONESHOT 事件。
1、为什么要需要重置 EPOLLONESHOT 事件?
重置 EPOLLONESHOT 事件的目的是确保该文件描述符再次有事件发生时能够被 epoll_wait 监视到并唤醒主线程进行处理。EPOLLONESHOT 事件在触发一次后,会自动从 epoll 实例的监视列表中移除,因此需要在工作线程处理完成后手动重新注册该事件。
触发一次后停止监视:EPOLLONESHOT 事件在触发一次后,不再监视该文件描述符上的事件。
防止多个线程同时处理:确保在任一时刻只有一个线程处理某个文件描述符上的事件。
处理完成后重置:工作线程处理完成后,需要重置 EPOLLONESHOT 事件,以便该文件描述符上的新事件能够再次被 epoll_wait 捕获。
疑虑1:当使用 EPOLLONESHOT 时,如果一个事件触发且正在被一个线程处理,该文件描述符不会被 epoll 继续监视,直到事件被重新注册。如果在处理过程中有新的数据到来,而事件尚未被重置,则可能导致数据丢失或无法及时处理。因此,重置 EPOLLONESHOT 事件必须在适当的时机进行。
确保重置及时的处理方式
为了确保在处理完成后能立即监视到新到来的数据,可以采取以下措施:
1、在处理完成后立即重置 EPOLLONESHOT:在每次处理完成后,立刻重置 EPOLLONESHOT,保证在最短时间内重新监视该文件描述符上的事件。
2、检查是否有剩余数据:在处理完成后,检查是否还有未处理的数据,如果有,继续处理直到数据处理完毕,再重置 EPOLLONESHOT。
解决办法代码展示:
void handleClient(int epollFd, int clientFd) {
char buffer[1024];
bool keepReading = true;
while (keepReading) {
int bytes_read = read(clientFd, buffer, sizeof(buffer));
if (bytes_read > 0) {
std::cout << "Read data: " << std::string(buffer, bytes_read) << std::endl;
// Process data...
} else if (bytes_read == 0) {
// Connection closed by client
close(clientFd);
keepReading = false;
} else {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
// No more data to read
keepReading = false;
} else {
// Read error
std::cerr << "Read error: " << strerror(errno) << std::endl;
close(clientFd);
keepReading = false;
}
}
}
// 重置 EPOLLONESHOT 事件
resetOneshot(epollFd, clientFd);
}
void resetOneshot(int epollFd, int fd) {
epoll_event event;
event.data.fd = fd;
event.events = EPOLLIN | EPOLLET | EPOLLONESHOT;
epoll_ctl(epollFd, EPOLL_CTL_MOD, fd, &event);
}
关键点解释
1、循环读取数据:使用一个循环读取数据,确保在有数据可读时能全部读取完毕。这样可以避免在重置 EPOLLONESHOT 事件之前有未处理的数据。
2、处理完成后立即重置 EPOLLONESHOT:在数据读取完成后,立刻重置 EPOLLONESHOT 事件,以便 epoll 能再次监视该文件描述符上的事件。
处理新到数据的逻辑(这是解释第一条实际操作的原因)
如果在某个线程处理完数据并重置 EPOLLONESHOT 事件之前,有新数据到达内核缓冲区,那么:读取时 EAGAIN 或EWOULDBLOCK:这些错误表示当前没有更多数据可读,但在处理完成后,如果重置 EPOLLONESHOT 事件,epoll 会继续监视该文件描述符上的事件。重置事件后:在处理完成并重置 EPOLLONESHOT 事件后,epoll 会立即检测到任何新到的数据并触发事件通知。
通过这种方式,可以确保在处理完成后,能够及时捕获并处理新到的数据事件,从而实现高效的并发I/O处理。