五种IO阻塞模型
一、准备知识
1、首先我们需要知道的是为什么会产生阻塞?
简单说就是IO操作时很费时的,当进程需要进行IO操作或者访问共享存储区的时候,该进程这时候有很长一段时间是用不到CPU的,那为了节省资源,这时候我们可以让CPU去干点别的事。这就是阻塞。
2、阻塞IO是如何唤醒的呢?
阻塞IO在读数据时会因为内核没有准备好数据而进入阻塞, 这时候内核的运行队列的该进程会 被加入到阻塞队列,当网络有数据传来时,网卡接收到数据并将数据写内存,然后发送中断信号,操作系统信号执行中断程序,这时候会将网络数据写入到内核相应的缓冲区,然后将该进程加入到就绪队列中,等待分配CPU。
二、阻塞IO
定义:当引用程序发起读数据申请时,内核还没有准备好数据,此时就会阻塞等待一直到内核准备好数据。
我们知道定义后可以实现一个简单的TcpServer了
#include <errno.h>
#include <netinet/in.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <iostream>
#define MAXSIZE 1024
int main(int atgc, char *argv[]) {
int listenfd = -1;
int connectfd = -1;
int n = 0;
char buffer[MAXSIZE];
// 初始化服务端, 指定协议为Ipv4,设置可以连接任意IP
struct sockaddr_in serv_addr;
socklen_t serv_len = sizeof(serv_addr);
memset(&serv_addr, 0x00, serv_len);
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(9999);
// 创建套接字
listenfd = socket(AF_INET, SOCK_STREAM, 0);
if( -1 == listenfd ) {
std::cout << "Create socket error: " << strerror(errno) << " Error:" << errno << std::endl;
return -1;
}
// 绑定
if( -1 == bind(listenfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) ) {
std::cout << "Bind listenfd error: " << strerror(errno) << " Error:" << errno << std::endl;
return -1;
}
// 监听
if (listen(listenfd, 10) == -1) {
std::cout << "Listen listenfd error: " << strerror(errno) << " Error:" << errno << std::endl;
return -1;
}
// 接收连接请求
struct sockaddr_in *cli_addr;
socklen_t cli_len = sizeof(cli_addr);
memset(&cli_addr, 0x00, cli_len);
// 如果没有新连接请求,就会一直阻塞在这里,直到连接到来才会返回
connectfd = accept(listenfd, (struct sockaddr*)&cli_addr, &cli_len);
if( -1 == connectfd ) {
std::cout << "Accept connectfd error: " << strerror(errno) << " Error:" << errno << std::endl;
return -1;
}
// 处理
while(1) {
n = recv(connectfd, buffer, sizeof(buffer), 0);
if( n > 0 ) {
buffer[n] = '\0';
std::cout << "Recv message from client:" << buffer << std::endl;
// send只是将数据放到协议栈里,并不一定表示发送成功,所以客户端数据显示具有随机性
send(connectfd, buffer, sizeof(buffer), 0);
}
else if( n == 0 ) {
close(connectfd);
}
}
return 0;
}
1、这里使用send时注意一个问题,send操作只是将数据copy到内核的缓冲区,但什么时候通过网络发送出去,是由协议栈决定的,那也就是时候,send成功并不等于数据发送成功。
2、这里实现了一连接一请求,但当存在多个连接请求时,这时候连接是会建立成功的,但是因为没有执行accept,这时候客户端fd,所以是无法接发数据的。
那如果将accept放到循环里呢,这样通过不断循环当多个客户端请求来临时,accpet就会建立连接了。
while (1)
{
// 处理连接请求
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
memset(&cli_addr, 0x00, cli_len);
// 循环accept,这样能保证可以连接多个客户端,如果没有客户端连接就会阻塞到这里
// 此时连接后成功接发一次数据,下次循环时会阻塞在accept那
connfd = accept(listenfd, (struct sockaddr *)&cli_addr, &cli_len);
if (-1 == connfd)
{
std::cout << "Accept listenfd error: " << strerror(errno) << " Error:" << errno;
return -1;
}
len = recv(connfd, buffer, MAXSIZE, 0);
if (len > 0)
{
buffer[len] = '\0'; // 字符串结束标志
std::cout << "Recv Message From Client: " << buffer << std::endl;
// 将数据写会到客户端
send(connfd, buffer, len, 0);
}
else if (len == 0)
{
close(connfd);
}
}
这样就解决了第一版server遇到的问题,允许多个客户端连接,原因就是循环会一直执行accept,当新连接返回时会分配一个客户端fd。
但是这样会出现一个新问题,那就是每个客户端只能接发一次数据,原因就是当内核没准备好数据时会阻塞在recv这里,当数据准备完成后,recv就会执行完成,这样,这里循环就结束了,下一次循环accpet之后,recv会阻塞在新的客户端,这时候之前的客户端就没办法执行recv,也就不能再接发数据了。所以这种解决办法是不行的。
这个问题会在多线程哪里解决掉,我们先来看看非阻塞IO
三、非阻塞IO
定义:不会阻塞等待直到新连接过来,会立即返回,并去处理已用的连接请求的事件
int main(int argc, char *argv[])
{
char buffer[MAXSIZE];
int len = 0;
// 初始化服务端
struct sockaddr_in serv_addr;
socklen_t serv_len = sizeof(serv_addr);
memset(&serv_addr, 0x00, serv_len);
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(8888);
// 创建套接字
int listenfd = socket(AF_INET, SOCK_STREAM, 0); // ipv4, TCP协议
if (-1 == listenfd)
{
std::cout << "Create socket error: " << strerror(errno) << " Error:" << errno;
return -1;
}
// 绑定
if (-1 == bind(listenfd, (struct sockaddr *)&serv_addr, serv_len))
{
std::cout << "Bind listenfd error: " << strerror(errno) << " Error:" << errno;
return -1;
}
int flags = fcntl(listenfd, F_GETFL, 0);
flags |= O_NONBLOCK;
fcntl(listenfd, F_SETFL, flags);
// 监听
if (-1 == listen(listenfd, 10))
{
std::cout << "Listen listenfd error: " << strerror(errno) << " Error:" << errno;
return -1;
}
int connfd = -1;
while (1)
{
// 处理连接请求
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
memset(&cli_addr, 0x00, cli_len);
connfd = accept(listenfd, (struct sockaddr *)&cli_addr, &cli_len);
if (-1 == connfd)
{
std::cout << "Accept listenfd error: " << strerror(errno) << " Error:" << errno;
return -1;
}
len = recv(connfd, buffer, MAXSIZE, 0);
if (len > 0)
{
buffer[len] = '\0'; // 字符串结束标志
std::cout << "Recv Message From Client: " << buffer << std::endl;
// 将数据写会到客户端
send(connfd, buffer, len, 0);
}
else if (len == 0)
{
close(connfd);
}
}
这版代码会在accept出直接返回,原因就是在绑定套接字之后,使用fcntl函数设置了listenfd为非阻塞,这样在accept处就会不等待直接返回,标准用法:
int flags = fcntl(listenfd, F_GETFL, 0);
flags |= O_NONBLOCK;
fcntl(listenfd, F_SETFL, flags);
这版代码没啥好说的,就是看一个非阻塞IO是什么效果就行了。
多线程io 解决多客户端连接问题
这里我们可以通过多线程思路解决,就是我们在while循环里还是继续accept,但当连接请求过来时,accept分配好connfd之后,我们注册一个线程,让这个线程去处理读写操作,这样就可以做到读写数据分离和多连接了。
// 线程处理函数
void *client_routine(void *arg) {
int connfd = *(int *)arg;
char buffer[MAXSIZE];
// 这里处理的知识客户端一次发送的数据
while(1) {
int len = recv(connfd, buffer, MAXSIZE, 0);
if(len > 0) {
buffer[len] = '\n';
buffer[len + 1] = '\0';
std::cout << "Recv Message From Client: " << buffer << std::endl;
send(connfd, buffer, len, 0);
}
else if(len == 0) {
// 关闭套接字,结束连接
close(connfd);
}
}
}
int main(int argc, char *argv[]) {
struct sockaddr_in serv_addr;
socklen_t serv_len = sizeof(serv_addr);
memset(&serv_addr, 0x00, serv_len);
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_addr.sin_port = htons(7777);
// 创建套接字
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if(-1 == listenfd) {
std::cout << "Create Socket error: " << strerror(errno) << " Error:" << errno;
return -1;
}
// 绑定
if(-1 == bind(listenfd, (struct sockaddr*)&serv_addr, serv_len)) {
std::cout << "Bind Listenfd error: " << strerror(errno) << " Error:" << errno;
return -1;
}
// 监听
if( -1 ==listen(listenfd, 100) ) {
std::cout << "Listen listenfd error: " << strerror(errno) << " Error:" << errno;
return -1;
}
while(1) {
//连接处理
struct sockaddr_in cli_addr;
socklen_t cli_len = sizeof(cli_addr);
int connfd = accept(listenfd, (struct sockaddr*)&cli_addr, &cli_len);
if(-1 == connfd) {
std::cout << "Accept connfd error: " << strerror(errno) << " Error:" << errno;
return -1;
}
// 创建线程处理发送接收请求
pthread_t thread_id = -1;
pthread_create(&thread_id, NULL, client_routine, (void*)&connfd);
}
return 0;
}
其实这版server和上面阻塞IO的第二版是很相似的,只是这里将客户端的读写放到了线程的回调函数里去了,这样我们在建立完连接之后,就可以在线程的回调函数里去循环接发数据了。这种思路解决了上面提到的问题,当这里有一些缺点,那就是随着连接请求的增大,及时我们继续增加计算机的内存,线程的数量很难突破C10K的限制,这是没有办法避免的,而且,一个连接就占用一个线程,这是不合理,因为线程资源是很宝贵的,我们希望,只有当客户端执行读写操作时才调用线程执行,这样才符合需求。
那这就是IO多路复用实现的思路了,就是使用一个线程去统一处理连接请求,然后获取有哪些fd需要执行读写操作,然后我们再让这些fd去调用线程。
四、IO多路复用——select
select工作原理就是在应用层调用select函数进入阻塞队列,这个时候,kernel内核就会轮询检查所有select负责的文件描述符fd,当找到其中哪个数据准备好了文件描述符,会返回给select,select通知系统调用,将数据从内核复制到进程的缓存区。我们来看代码
// 使用select
fd_set rfds, rset, wfds, wset;
FD_ZERO(&rfds);
FD_ZERO(&wfds);
FD_SET(listenfd, &rfds); //设置监听的套接字位
int maxfd = listenfd;
unsigned char buffer[MAXSIZE] = {0}; // 0
int ret = 0;
int count = 0;
while(1) {
// 复制读写集合
rset = rfds;
wset = wfds;
// 检查读写集合中到的bit位确定是否有对应事件发生
int nready = select(maxfd + 1, &rset, &wset, NULL, NULL); // 1、这里是将复制的读写集合放进内核中
if(FD_ISSET(listenfd, &rset)) { // 判断监听的fd是否有新连接
struct sockaddr_in client;
socklen_t cli_len = sizeof(client);
int connfd = accept(listenfd, (struct sockaddr*)&client, &cli_len);
count ++;
if(-1 == connfd) {
std::cout << "Accept connfd error: " << strerror(errno) << " Error:" << errno;
return -1;
}
// 将客户端fd加入到读集合事件中,也就是将原始读集合中对应bit位置1
FD_SET(connfd, &rfds);
if (connfd > maxfd)
maxfd = connfd;
if (--nready == 0) continue;
}
int i = 0;
// 从监听的fd开始循环,因为客户端新连接的fd不能在监听fd之前
for(i = listenfd + 1; i <= maxfd; i++) { // 2、从fd = 0开始轮询,浪费太多CPU资源
if(FD_ISSET(i, &rset)) {
ret = recv(i, buffer, MAXSIZE, 0); // recv会阻塞,知道内核有数据
if(ret == 0) {
count--;
close(i);
FD_CLR(i, &rfds); // 断开连接,重置该fd相应的bit位
}
else if(ret > 0) {
std::cout << "Buffer: " << buffer << std::endl;
FD_SET(i, &wfds);
}
}
else if (FD_ISSET(i, &wset)) {
ret = send(i, buffer, ret, 0);
FD_CLR(i, &wfds);
FD_SET(i, &rfds);
}
}
std::cout << "Count:" << count << std::endl;
}
这里我们首先需要定义读写事件集合,然后将复制一份,再将复制的读写集合作为select函数的参数传递进入,然后select进入内核并将进程加入到阻塞队列,这时内核会调用copy_from_user将三个集合复制到内核中,然后经过一系列的函数调用,最终返回,如果返回值大于0表示select成功,这时候我们需要轮询从监听fd开始到分配的最大fd,去检查这个范围内的每个fd是否发生读写事件,如果有就做相应处理。
从上面的过程我们可以看出select的部分缺点:
1、每次调用select,都需要把fd集合从用户态拷贝到内核态,这个开销在fd很多时会很大
2、同时每次调用select都需要在内核遍历传递进来的所有fd,这个开销在fd很多时也很大,每个fd_set是1024,则三个就是3072
3、select支持的描述符的数量比较小,默认是1024,即数据类型fd_set的大小
4、select每次返回时,readfds和writefds都将相应的准备就绪的fd对应的位置位,下次调用时,需要重新初始化这些参数。
5、select返回的只是准备就绪的描述符的数量,具体哪一个描述符准备好了还需要应用程序一个一个进行判断
五、IO多路复用——poll
poll的实现原理和select差不多,它的主要核心在pollfd结构体数据,这个结构体有三个参数,fd存储要操作的fd,event表示监听到的时间,revent表示实际发生的事件,是由内核填写的,这样我们在使用时首先第一个需要将监听的fd加入进去,并将event设置为监听读写时间,然后将其他的结构体数组成员分别进行初始化,然后再调动poll函数
#include <iostream>
#include <errno.h>
#include <netinet/in.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/poll.h>
#define MAXSIZE 1024
int main(int argc, char *argv[])
{
char buffer[MAXSIZE] {0};
// 初始化服务端
struct sockaddr_in serv_addr;
socklen_t serv_len = sizeof(serv_addr);
memset(&serv_addr, 0x00, serv_len);
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl("");
serv_addr.sin_port = htons(9999);
// 创建套接字
int listenfd = socket(AF_INET, SOCK_STREAM, 0);
if(-1 == listenfd) {
std::cout << "Create socket error: " << strerror(errno) << " Error:" << errno;
return -1;
}
// 绑定套接字
if( bind(listenfd, (struct sockaddr*)&serv_addr, serv_len) == -1) {
std::cout << "Bind listenfd error: " << strerror(errno) << " Error:" << errno;
return -1;
}
// 监听套接字
if(listen(listenfd, 10) == -1) {
std::cout << "Listen listenfd error: " << strerror(errno) << " Error:" << errno;
return -1;
}
struct pollfd clientfds[MAXSIZE];
// 初始化结构体
clientfds[0].fd = listenfd;
clientfds[0].events = POLLIN;
std::cout << "Events:" << clientfds[0].events << " FD:" << clientfds[0].fd << std::endl;
clientfds[0].revents = 0;
for(int i = 1; i < MAXSIZE; i++) {
clientfds[i].fd = -1;
clientfds->events = 0;
clientfds->revents = 0;
}
int client_MaxFD = 0;
while(1) {
int nready = poll(clientfds, client_MaxFD + 1, 1000);
std::cout << "Nready:" << nready << std::endl;
if(clientfds[0].revents & POLLIN) {
// 说明有新连接
struct sockaddr_in client; // 初始化客户端
socklen_t cli_len = sizeof(cli_len);
int connfd = accept(listenfd, (struct sockaddr*)&client, &cli_len);
if(-1 == connfd) {
std::cout << "Accept listenfd error: " << strerror(errno) << " Error:" << errno;
return -1;
}
// 将fd加入到集合中
for(int i = 1; i < MAXSIZE; i++) {
if(clientfds[i].fd == -1) {
clientfds[i].fd = connfd;
client_MaxFD++;
clientfds[i].events = POLLOUT;
break;
}
}
if(connfd > client_MaxFD)
client_MaxFD = connfd;
}
for(int i = 1; i < client_MaxFD; i++) {
if(clientfds[i].fd > 0 && clientfds[i].revents == POLLIN) {
int ret = recv(clientfds[i].fd, buffer, 20, 0);
if(ret < 0) {
clientfds[i].fd = -1;
clientfds[i].events = 0;
clientfds[i].revents = 0;
// 这里还需要重新设置client_MaxFD的值
}
ret = send(clientfds[i].fd, buffer, 20, 0);
}
}
}
return 0;
}
poll相对与select的优缺点
1、poll主要是解决select的最大文件描述符限制提出的,与select一样都是轮询文件描述符
2、pollfd数组也是需要复制进内核的,但是不需要每次重新赋值。
3、poll将用户传入的数组拷贝到内核空间,然后查询每个fd对应的设备状态,如果设备就绪则在设备等待队列中加入一项并继续遍历,如果遍历完所有fd后没有发现就绪设备,则挂起当前进程,直到设备就绪或者主动超时,被唤醒后它又要再次遍历fd。这个过程经历了多次无谓的遍历。
4、大量的fd的数组被整体复制于用户态和内核地址空间之间,而不管这样的复制是不是有意义。
5、poll还有一个特点是“水平触发”,如果报告了fd后,没有被处理,那么下次poll时会再次报告该fd。
六、IO多路复用——epoll
epoll是目前使用最广,也是效率最高的模型,很多服务器都是以epoll为基础设计的,epoll相比如select和poll主要提升在以下四个方面。
1、监视的描述符数量不受限制,它所支持的 fd 上限是最大可以打开文件的数目,这个数字一般远大于 2048, 举个例子, 在 1GB 内存的机器上大约是 10 万左右,具体数目可以 cat /proc/sys/fs/file-max 察看, 一般来说这个数目和系统内存关系很大。select 的最大缺点就是进程打开的 fd 是有数量限制的。这对于连接数量比较大的服务器来说根本不能满足。
2、IO 的效率不会随着监视 fd 的数量的增长而下降。epoll 不同于 select 和 poll 轮询的方式,而是通过每个 fd 定义的回调函数来实现的。只有就绪的 fd 才会执行回调函数。
3、支持水平触发和边沿触发两种模式,这两种模式后面介绍。
4、mmap 加速内核与用户空间的信息传递。epoll 是通过内核与用户空间 mmap 同一块内存,避免了无谓的内存拷贝。
// 开始使用epoll树和事件结构体
int epfd = epoll_create(1);
struct epoll_event ev, events[EVENT_LENGTH];
ev.events = EPOLLIN;
ev.data.fd = listenfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);
while(1) {
// 记录就绪队列中的连接数
int nready = epoll_wait(epfd, events, EVENT_LENGTH, -1);
printf("-----nready:%d-----\n", nready);
// 遍历就绪队列
for(int i = 0; i < nready; i++) {
int clientfd = events->data.fd;
// 首先判断fd是不是监听
if(listenfd == clientfd) {
struct sockaddr_in client;
socklen_t cli_len = sizeof(client);
memset(&cli_len, 0x00, cli_len);
int connfd = accept(listenfd, (struct sockaddr*)&client, &cli_len);
if(-1 == connfd) {
std::cout << "Accept connfd error: " << strerror(errno) << " Error:" << error;
return -1;
}
// 将connfd事件置为可读并加到epoll树上
events[i].events = EPOLLIN;
events[i].data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, connfd, &ev);
}
else if(events[i].events & EPOLLIN) {
while(1) {
int n = recv(clientfd, rbuffer, BUFFER_LENGTH, 0);
if(n > 0) {
rbuffer[n] = '\0';
std::cout << "Recv message from client: " << rbuffer << std::endl;
memccpy(wbuffer, rbuffer, BUFFER_LENGTH);
// 将fd事件置为可读
ev.events = EPOLLOUT;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &ev);
}
else if(n == 0) {
ev.events = EPOLLIN;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_DEL, clientfd, &ev);
close(clientfd);
}
}
}
else if(events[i].events & EPOLLOUT) {
int sent = send(clientfd, wbuffer, BUFFER_LENGTH, 0); //
printf("sent: %d\n", sent);
ev.events = EPOLLIN;
ev.data.fd = clientfd;
epoll_ctl(epfd, EPOLL_CTL_MOD, clientfd, &ev);
}
}
}
close(listenfd);
我们在使用epoll的时候归根结底就是使用epoll_create、epoll_ctl和epoll_wait三个函数,首先需要cpoll_create创建一个epfd根节点,然后调用epoll_ctl将listenfd加入到epfd上去,最后再调用epoll_wait 去等待连接,然后返回连接数,这时候我们不需要去轮询fd集合,epoll会告诉我们那些fd上有事件发生。需要注意的就是epoll_wait是监听挂载到epfd上的fd,那也就是说我们在连接成功后,必须将connfd加到epfd上去。
然后我们在循环遍历是需要分情况讨论,第一种就是fd是监听fd,这时候我们需要建立客户端,然后将该fd对应event时间置为EPOLLIN,最厚将connfd加入到epoll树上去。如果不是监听fd,我们需要判断是可读还是可写事件,然后最相应处理。
然后我们在来看看什么是LT和ET?
epoll默认是LT(水平触发),也就是一次事件会触发多次,比如当读事件来临时,内核有1024个字节数据可读,但是recv的buffer每次只会接受128个字节,那么这时候LT就会一直读,直到内核数据读完之后才结束。但是ET(边沿触发)就不是这样,他是一次事件触发一次,也就是说buffer读满128个字节数据就不会再从内核中拿数据了,必须等待下一次读事件发生,这时候会继续读取128个字节,这种情况会导致数据一致积压在内核缓冲区。
ET要与非阻塞fd一起使用,因为ET一次事件只触发一次,所以epoll_wait返回后一定要处理完毕,对于可读事件,要一直read fd到此fd被read完为止,而如果设置成blocking以后,fd上的数据read完后会阻塞,即while{epoll_wait(); read(fd)}这段代码会一直阻塞而影响重新调用epoll_wait来监听其他事件,正确做法是设置fd成non_blocking,且epoll_wait返回后吧事件read到EAGAIN为止,注意这里只是单线程下(不过哪怕是用线程池,线程池中线程阻塞了也进行不了其他任务)