1. IO模型 (IO的处理方法)
1.1 IO
Input / Output,即数据的读取 (接收) / 写入 (发送)操作,针对不同的数据存储媒介,大致可以分为网络 IO 和磁盘 IO 两种。在 Linux 系统中,为了保证系统安全,操作系统将虚拟内存划分为内核空间和用户空间两部分。因此用户进程无法直接操作 IO设备资源,需要通过系统调用完成对应的IO操作
IO模型也叫做输入输出模型,可以把 IO理解为两个步骤:
(1) 等待 IO事件就绪 (read:有数据到达,write:有空间)(2) 真正意义上面的数据迁移
举例说明,一个网络数据输入操作包括两个不同的阶段:
(1) 等待数据准备好
(2) 从内核空间向用户空间复制数据
1.2 缓存区
应用层的 IO 操作基本都是依赖操作系统提供的 read 和 write 两大系统调用实现。但由于计算机外部设备 (磁盘、网络)与内存、CPU 的读写速度相差过大,若直接读写涉及操作系统中断,因此为了减少 OS 频繁中断导致的性能损耗和提高吞吐量,引入了缓冲区的概念。根据内存空间的不同,又可分为内核缓冲区和进程缓冲区。操作系统会对内核缓冲区进行监控,等待缓冲区达到一定数量的时候,再进行 IO 设备的中断处理,集中执行物理设备的实际 IO 操作,通过这种机制来提升系统的性能。至于具体什么时候执行系统中断 (包括读中断、写中断) 则由操作系统的内核来决定,应用程序不需要关心
1.3 同步 / 异步、阻塞 / 非阻塞阻塞 / 非阻塞
关注的是用户态进程 / 线程的状态,它要访问的数据是否就绪,进程 / 线程是否需要等待
阻塞 IO指的是需要内核 IO操作彻底完成后才返回到用户空间执行用户程序的操作指令
非阻塞 IO (Non-Blocjing IO,NIO) 指的是用户进程不需要等待内核 IO操作彻底完成,即可返回用户空间执行后续指令。与此同时,内核会立即返回给用户一个 IO 状态值
阻塞是指用户进程一直在等待,而不能做别的事情;非阻塞是指用户进程获得内核返回的状态值就返回自己的空间,可以去做别的事情
同步 / 异步
关注的是消息通信机制 (内核与应用程序的交互)
同步 IO是指用户空间 (进程或者线程)是主动发起 IO 请求的一方,系统内核是被动接收方
异步 IO则反过来,系统内核是主动发起 IO 请求的一方,用户空间是被动接收方。异步只需要关注IO操作完成的通知,并不主动读写数据,由操作系统内核完成数据的读写
同步和异步最大的区别就是被调用方的执行方式和返回时机。同步指的是被调用方做完事情之后再返回,异步指的是被调用方先返回,然后再做事情,做完之后再想办法通知调用方 (此时数据已被内核读取完毕并放在用户缓冲区内,调用方可以直接使用)
阻塞和非阻塞关注的是调用方进程状态,而同步与异步更关注调用双方进程通信方式5种常见的IO模型 (输入输出的处理方式)
(1) 阻塞IO
同步阻塞IO (Blocking IO, BIO) 指的是用户进程(或线程)主动发起,需要等待内核 IO 操作彻底完成后才返回到用户空间的 IO 操作。在 IO 操作过程中,发起 IO 请求的用户进程处于阻塞状态读:
如果有数据 (即便数据少于你要读取的字节数),直接返回数据
如果没有数据,则阻塞 (等待IO事件就绪)直到有数据或者出错写:
如果有空间可写 (即便空间少于你要写的字节数),直接写入
如果没有空间,则阻塞 (等待IO事件就绪)直到有空间或者出错
最常用,最简单,效率最低的IO,默认的IO模型
在IO过程中“阻塞”的时间越短,IO的效率越高
优点:开发简单,容易入门。在阻塞等待期间,用户线程挂起,在挂起期间不会占用 CPU 资源缺点:一个线程维护一个 IO ,不适合大并发,在并发量大的时候需要创建大量的线程来维护网络连接,线程开销非常大
(2) 非阻塞IO (不等待的IO)
同步非阻塞IO (Non-Blocking IO,NIO) 指的是用户进程主动发起,不需要等待内核 IO 操作彻底完成就能立即返回用户空间的 IO 操作。在 IO 操作过程中,发起 IO 请求的用户进程处于非阻塞状态
(1) 当数据 Ready 之后,用户线程仍然会进入阻塞状态 (也就是上图"复制数据这个过程" --->从内核空间向用户空间复制数据),直到数据复制完成,并不是绝对的非阻塞(2) NIO 实时性好,内核态数据没有 Ready 会立即返回,但频繁的轮询内核,会占用大量的 CPU 资源,降低效率
能读(有数据)就读,不能读(没有数据)则立即返回一个错误码
能写(有空间)就写,不能写(没有空间)则立即返回一个错误码
虽然不会阻塞,但是也有一个缺点,因为每次请求都可以立即返回,用户为了获取数据,需要不断的“轮询”,消耗了大量的CPU时间,在实际的应用中,这种模型用的地方不多
优点:每次发起 IO 调用,在内核等待数据的过程中可以立即返回,用户线程不会阻塞,实时性好缺点:多个线程不断轮询内核是否有数据,占用大量 CPU 资源,效率不高
(3) IO多路复用 (多路:多个文件,复用:同一个线程)
IO 多路复用 (IO Multiplexing)实际上就解决了 NIO 中的频繁轮询 CPU 的问题,并且引入一种新的系统调用,如:select / poll / epoll
复用 IO 的基本思路就是通过 select 调用来监控多 fd (文件描述符),来达到不必为每个 fd 创建一个对应的监控线程的目的,从而减少线程资源创建的开销。一旦某个描述符就绪 (一般是内核缓冲区可读 / 可写),内核就能够将文件描述符的就绪状态返回给用户进程(或线程),用户空间可以根据文件描述符的就绪状态进行相应的 IO 系统调用允许同时对多个IO事件进行控制,同时监控多个“文件描述符”
select / poll / epoll
因为同时对多个IO事件进行监测,效率比前面两种IO方式高
优点:不必创建维护大量线程,只使用一个线程就可以同时处理成千上万连接,大大减少了系统开销缺点:本质上,select 和 epoll 的系统调用是阻塞式的,属于同步 IO,需要在读写时间就绪后,由系统调用,进行阻塞的读写
(4) 信号驱动IO
信号驱动 IO模型通过信号机制来实现 I/O操作的异步通知。当进程对某个描述符 (如文件描述符、套接字等)执行了I/O操作,并希望在该描述符上发生特定事件 (如数据可读、可写、异常等)时得到通知,它可以向内核注册一个信号处理函数,并启用信号驱动IO模式。当指定的事件发生时,内核会向进程发送一个信号 (如SIGIO或SIGPOLL),进程在接收到信号后,会调用之前注册的信号处理函数来处理该事件如果有IO事件就绪了,就可以发一个信号给应用程序,进行数据的迁移
(5) 异步IO
异步IO (Asynchronous IO,AIO) 指的是用户空间的线程变成被动接收者,而内核空间成为主动调用者。 在异步 IO 模型中,当用户线程收到通知时,数据已经被内核读取完毕并放在用户缓冲区内,内核在 IO 完成后通知用户线程直接使用即可
仅仅是发起对数据的请求,不需要自己进行数据的迁移前面四个IO模型都有等待和数据迁移的两个阶段,对于异步IO,仅仅是发起对数据的请求,没有自己等待,也没有自己进行数据的迁移
2. 改变一个已经打开的文件的状态
在open的时候
加上 O_NONBLOCK ---> 以非阻塞的方式打开
没加 O_NONBLOCK ---> 默认就是阻塞 IO
我们可以改变一个已经打开的文件的状态O_NONBLOCK 属于一个文件的状态,我们可以在打开文件后,改变文件的状态
fcntl:可以改变一个文件的状态属性文件的状态属性分为两种:
文件本身的状态:
O_RDONLY
O_WRONLY
O_RDWR
O_APPEND
O_NONBLOCK
......
这些标志,保存在一个 struct file 结构体中的一个成员变量中:
unsigned long f_flags
通过位域实现 ,状态只是变量中的某一个位
文件描述符状态:
暂时只有一个,FD_CLOEXEC
fork进程的时候,文件描述符被复制,如果exec改变进程的数据,原来的文件描述符就没有作用,理应被关闭
FD_CLOEXEC close on exec
在exec的时候,文件描述符被关闭NAME fcntl - manipulate file descriptor // 操作文件描述符 SYNOPSIS #include <unistd.h> #include <fcntl.h> fcntl改变文件的状态,具体的操作由命令号决定 int fcntl(int fd, int cmd, ... /* arg */ ); -------------------------------------------------------------------- fcntl的功能有5种: 1. 复制一个现有的文件描述符(cmd == F_DUPFD) 让多个文件描述符指向同一个文件 复制一个现有的文件描述符fd,新的文件描述符作为函数的返回值返回 new_fd = fcntl(old_fd, F_DUPFD, 4); 第三个参数:指定新文件描述符的最小值,返回的文件描述符最小为4 -------------------------------------------------------------------- 2. 获取/设置文件描述符标记(FD_CLOEXEC) cmd == F_GETFD / cmd == F_SETFD F_GETFD:第三个参数被忽略 对应的文件描述符的状态标记作为返回值返回 r = fcntl(fd, GETFD); F_SETFD: 新的文件描述符标记按照第三个参数设置 fcntl(fd, SETFD, new_s); ---------------------------------------------------------------------- 3. 获取/设置文件状态标记 cmd == F_GETFL / cmd == F_SETFL F_GETFL:第三个参数被忽略 文件所有的状态标记作为返回值返回 F_SETFL: 新的文件状态按照第三个参数设置 例子: 3.1. 判断一个打开的文件是否是非阻塞(0:阻塞) unsigned long f_flags = fcntl(fd, F_GETFL); // 获取文件状态 if (f_flags & O_NONBLOCK) { // 文件是非阻塞的 } else { // 文件是阻塞的 } 3.2. 设置文件的阻塞方式 unsigned long f_flags = fcntl(fd, F_GETFL); // 获取文件状态 f_flags &= ~O_NONBLOCK; // 去掉非阻塞标准,其他标志不动 fcntl(fd, F_SETFL, f_flags); 在Linux上,该命令只能改变O_APPEND、O_ASYNC、O_DIRECT、 O_NOATIME 和 O_NONBLOCK标志 ----------------------------------------------------------------------- 4. 获取/设置文件属主进程 ----------------------------------------------------------------------- 5. 获取/设置文件记录锁 当一个进程正在修改或者读取文件的时候,可以阻止另一个进程去访问文件
3. IO多路复用的实现方式 (select / poll / epoll)
IO多路复用,一种同步IO模型,单个进程 / 线程就可以同时处理多个IO请求
- 一个进程 / 线程可以监听多个文件句柄 (文件描述符)
- 一旦某个文件句柄就绪 (可读、可写、出错),就能够通知应用程序进行相应的读写操作
- 没有文件句柄就绪 (可读、可写、出错)时会阻塞应用程序,交出CPU
一个进程 / 线程虽然任一时刻只能处理一个请求,但是处理每个请求的事件时,耗时控制在 1毫秒以内,这样1秒内就可以处理上千个请求,把时间拉长来看,多个请求复用了一个进程 / 线程,这就是多路复用,这种思想很类似一个CPU并发多个进程,所以也叫做时分多路复用
用来实现对多个文件描述符进行 IO的"监听"一般用在网络服务器中,可以并发的处理多个客户端的连接和请求
例子:
一个服务器,可能同时会有多个客户端与之发生通信
===>
IO 多路复用,监听每个客户端的读写状态 以及 服务器的连接状态
3.1 select
select工作方式:使用一个文件描述符集合来监听多个IO事件的就绪状态。应用程序将需要监听的文件描述符添加到集合中,然后调用select函数进行监听。当有文件描述符就绪时,select函数会返回,并告知哪些文件描述符已经准备好进行读取或写入操作。然后应用程序可以通过遍历文件描述符集合来处理就绪的IO事件
(1) 首先用户线程发起select系统调用的时候会阻塞在select系统调用上,同时用户线程将需要监听的socket 对应的文件描述符fd数组通过select系统调用传递给内核此时不仅完成了用户线程从用户态切换到内核态完成了一次上下文切换(状态切换),同时用户线程将用户空间的fd数组拷贝到了内核空间(内容拷贝)
(2) 当用户线程调用完select后进入阻塞状态,内核开始轮询遍历fd数组,查看fd对应的socket 接收缓冲区是否有数据到达,如果有数据到来,则将对应的位图的值设置为1,若没有数据到来,则为0
(3) 在内核遍历一遍fd数组后,如果发现有一些 fd有数据到来,则将修改后的fd数组返回用户线程。此时会将fd数组从内核空间拷贝到用户空间
此时不仅完成了用户线程从内核态切换到用户态完成了一次上下文切换(状态切换)。还同时内核将内核空间的fd数组拷贝到了用户空间(内容拷贝)
(4) 当内核将修改后的 fd数组给用户线程之后,用户线程结束阻塞,开始遍历fd数组,找出 fd数组值为1的socket 文件描述符。最后对这些socket发起系统调用读取数据
select 不会告诉用户线程具体那些fd有IO数据到来,只是在活跃的fd打上标记,然后将标记完整fd数组返回给用户线程,所以用户线程还需要遍历fd数组已找出那些fd有IO数据到来
由于内核在遍历的过程中修改了fd数组,所以在用户线程遍历完fd数组后获取到IO就绪的socket之后,就需要重置fd数组,并需要重新调用select传入重置后的fd数组,让内核发起新一轮遍历轮询
select 底层原理:位图-bitmap// fd_set的底层,实际就是用的 位图 这个数据结构 typedef __kernel_fd_set fd_set; #undef __FD_SETSIZE #define __FD_SETSIZE 1024 typedef struct { unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))]; } __kernel_fd_set; 首先看数组的大小: sizeof(long)如果是64位计算机系统,则占8个字节, 所以这个数组就是(1024/(8*8)) = 16个元素。 由此可知 fd_set实际上是一个16个元素的long数组, 用位存储的思想可以存放16 * 8 * 8 = 1024个文件描述符
select 编程一般步骤:
创建监听集合:fd_set
初始化监听集合:FD_ZERO
增加监听:FD_SET
调用select函数:进程陷入阻塞,当监听的所有fd,有任何一个就绪,select 解除阻塞
fd_set 从监听集合变为就绪集合,使用FD_ISSET 判断某个fd是否就绪
函数接口:
NAME select, pselect, FD_CLR, FD_ISSET, FD_SET, FD_ZERO - synchronous I/O multiplexing // 同步IO的多路复用 SYNOPSIS /* According to POSIX.1-2001 */ #include <sys/select.h> /* According to earlier standards */ #include <sys/time.h> #include <sys/types.h> #include <unistd.h> select用类型fd_set来表示一个文件描述符集合,因为你需要监听多个文件描述符, 需要一个fd_set类型的集合来保存你所有需要监听的文件描述符,但是你可能有的文 件描述符是监听是否可读,有的是监听是否可写,有的是监听是否出错 所以,就有三个文件描述符集合分别存储你要监听的文件描述 readfds:监听可读的文件描述符 writefds:监听可写的文件描述符 exceptfds:监听出错的文件描述符 关于文件描述符集合,有以下一些操作: void FD_ZERO(fd_set *set); 把set指向的文件描述符集合清空 void FD_CLR(int fd, fd_set *set); 把fd指定的文件描述符从set指定的集合中移除 void FD_SET(int fd, fd_set *set); 把fd指定的文件描述符加入到set指定的集合中去 int FD_ISSET(int fd, fd_set *set); 判断fd指定的文件描述符是否存在于set指定的集合中 返回值:1 存在 0 不存在 ----------------------------------------------------------------------- int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout); nfds:你需要监听的所有文件描述符中最大值+1 内核中是从0号文件描述符开始轮询,到你指定的最大的文件描述符 readfds:监听可读的文件描述符集合 返回的时候,把整个集合返回,此时,可读的文件描述符已经被标记了 writefds:监听可写的文件描述符集合 返回的时候,把整个集合返回,此时,可写的文件描述符已经被标记了 exceptfds:监听出错的文件描述符集合 返回的时候,把整个集合返回,此时,出错的文件描述符已经被标记了 上面三个集合,如果不需要可以指定为空 如: select(nfds, readfds, NULL, NULL, timeout); 只监听可读的文件描述符集合 timeout:超时时间,在指定的时间内,还没有文件描述符就绪,那么返回 struct timeval { long tv_sec; /* 秒 */ long tv_usec; /* 毫妙 */ }; a.把该参数设置为NULL,表示一种等待下去,直到至少有一个文件描述符就绪 b.等待一段固定的时间(描述在timeval),在等待时间内,正常的等待,一旦超时还没有文 件描述符就绪,就立即返回 c.根本不等待,轮询一次后立即返回,该参数必须指向一个timeval的结构体,且结构体中的 定时值为0 select正常返回后,timeval中的值,被设置为剩余时间数 返回值: >0 表示已经就绪的文件描述符个数 由于你监听了多个文件描述符,至于到底是哪些文件描述符就绪了,你必须在select返回 后,使用FD_ISSET一个一个去测试 =0 超时了,等待的指定的时间到了 <0 select函数出错了,errno被设置
例子: 1)select 延时效果 struct timeval timeout; timeout.tv_sec = 10; timeout.tv_usec = 0; select(10, NULL, NULL, NULL, &timeout);
(2)IO多路复用 利用select 实现基于TCP的一对多的通信 #include <stdio.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <string.h> #include <unistd.h> #include <stdlib.h> #include <sys/select.h> #include <sys/time.h> /* select_server.c IO多路复用 服务器 */ int main(int argc, char *argv[]) { // 1.创建套接字 socket TCP int sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd == -1) { perror("socket failed"); return -1; } // 设置端口号重用 int enable = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEPORT, &enable, sizeof(enable)); // 2.绑定服务器的ip和端口 bind struct sockaddr_in sAddr; sAddr.sin_family = AF_INET; // 协议族 sAddr.sin_port = htons(atoi(argv[2])); // 端口号(网络字节序) inet_aton(argv[1], &sAddr.sin_addr); // ip地址 int re = bind(sockfd, (struct sockaddr *)&sAddr, sizeof(sAddr)); if (re == -1) { perror("bind failed"); close(sockfd); return -1; } // 3.监听 listen re = listen(sockfd, 5); if (re == -1) { perror("listen failed"); close(sockfd); return -1; } printf("listen success\n"); //========== select() IO多路复用 ================================= int client_fd[250] = {0}; // 存储已经连接到服务器的客户端的套接字描述符 int num = 0; // 记录客户端的个数 struct sockaddr_in client_addr[256]; // 保存客户端的网络地址 int max_fd = sockfd; // 文件描述符中的最大值,初始时 最大值一定是服务器的套接字描述符 // 超时时间 struct timeval timeout; // 可读的集合 fd_set read_fds; while (1) { // select正常返回后,timeval中的值,被设置为剩余时间数 timeout.tv_sec = 2; timeout.tv_usec = 0; // 将 read_fds(可读的集合) 清空 FD_ZERO(&read_fds); // 将 服务器的套接字 加入到 read_fds 集合中 FD_SET(sockfd, &read_fds); // 将 连接到服务器的客户端 的套接字 加入到 read_fds 集合中 for (int i = 0; i < num; i++) { FD_SET(client_fd[i], &read_fds); } // select 监听 re = select(max_fd + 1, &read_fds, NULL, NULL, &timeout); if (re > 0) { // 判断read_fds 集合中 是否有文件描述符就绪 // 1. 服务器就绪: 新的客户端来连接 // 2. 客户端就绪:表示客户端上有数据可读 // 服务器就绪 --> 去接受客户端的连接请求 if (FD_ISSET(sockfd, &read_fds)) { // 接受连接请求 accept socklen_t len = sizeof(client_addr[num]); int new_fd = accept(sockfd, (struct sockaddr *)&client_addr[num], &len); if (new_fd == -1) { perror("accept failed"); break; } printf("accept success\n"); printf("client ip = %s\n", inet_ntoa(client_addr[num].sin_addr)); // 把新的客户端的套接字保存 client_fd[num] = new_fd; num++; if (new_fd > max_fd) { // 更新文件描述符中的最大值 max_fd = new_fd; } } // 客户端就绪 ---> 读取数据 for (int i = 0; i < num; i++) { // 判断客户端的套接字描述符是否可读 if (FD_ISSET(client_fd[i], &read_fds)) { // 读取数据 char buf[128] = {0}; int r = recv(client_fd[i], buf, sizeof(buf), 0); if (r > 0) { printf("r = %d, buf = %s\n", r, buf); printf("client IP = %s\n", inet_ntoa(client_addr[i].sin_addr)); } if (buf[0] == '#') { // 客户端退出 close(client_fd[i]); } } } } else if (re == 0) { // 超时 printf("超时 \n"); } else { // 出错 perror("select error"); break; } } //关闭套接字 close(sockfd); }
3.2 poll
poll 工作方式:
poll 的原理基本上和select 相同,poll 在内核中是使用链表存储文件描述符集合,而select 是使用数据去存储的 (数组空间有限,所以最多只能监听1024个文件描述符),poll 监听的文件描述符不限个数
NAME poll - wait for some event on a file descriptor // 等待文件描述符上面的一些事件 SYNOPSIS #include <poll.h> /* poll的功能和作用与select一样,只不过poll是使用结构体,描述你要监听的文件描述符 以及你需要监听的事件 */ struct pollfd { int fd; /* 你要监听的文件描述符 */ short events; // 你要监听的事件(期待文件描述符发生什么事件),事件码使用位域实现 /* POLLIN:期待文件描述符可读 POLLOUT:期待文件描述符可写 POLLERR:期待文件描述符出错 .... */ short revents; // 返回已经就绪的事件 如:监听是否可读可写 if (revents & POLLIN) { 可读啦; } }; 要监听一个文件描述符就需要一个struct pollfd结构体 要监听多个文件描述符就需要多个struct pollfd结构体 .... ---------------------------------------------------------------------------------- int poll(struct pollfd *fds, nfds_t nfds, int timeout); fds:struct pollfd *的指针,指向你要监听的所有事件, struct pollfd的数组,数组中保存了你要监听的所有事件 nfds:上面的数组中的元素个数 timeout:超时时间,单位为毫秒 返回值: >0 表示已经就绪的文件描述符个数 由于你监听了多个文件描述符,至于到底是哪些文件描述符就绪了,你必须在poll返回后, 一个一个去测试事件数组(fds指向的数组) =0 超时了,等待的指定的时间到了 <0 poll函数出错了,errno被设置
IO多路复用
利用poll 实现基于TCP的一对多的通信#include <stdio.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <string.h> #include <unistd.h> #include <stdlib.h> #include <poll.h> #define MAX_NUM 256 /* select_server.c IO多路复用 服务器 */ int main(int argc, char *argv[]) { // 1.创建套接字 socket int sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd == -1) { perror("socket failed"); return -1; } // 设置端口号重用 int on = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEPORT, &on, sizeof(on)); // 2.绑定服务器的ip和端口 bind (服务器的ip和端口) struct sockaddr_in sAddr; sAddr.sin_family = AF_INET; // 协议族 sAddr.sin_port = htons(atoi(argv[2])); // 端口号(网络字节序) inet_aton(argv[1], &sAddr.sin_addr); // ip地址 int re = bind(sockfd, (struct sockaddr *)&sAddr, sizeof(sAddr)); if (re == -1) { perror("bind failed"); close(sockfd); return -1; } // 3.监听 listen re = listen(sockfd, 5); if (re == -1) { perror("listen failed"); close(sockfd); return -1; } printf("listen success\n"); // ========== poll() IO多路复用 ================================= // 定义一个 struct pollfd 结构体数组,来保存要监听的文件描述符 struct pollfd fds[MAX_NUM]; // 存储已经连接到服务器的客户端的套接字描述符 int client_fd[MAX_NUM] = {0}; int num = 0; // 记录客户端的个数 // 保存客户端的网络地址 struct sockaddr_in client_addr[256]; while (1) { // 把服务器的文件描述符 加入到 fds结构体数组中 fds[0] fds[0].fd = sockfd; fds[0].events = POLLIN; // 要监听的事件:可读 fds[0].revents = 0; // 初始化时是没有返回值的 // 将 连接到服务器的客户端 的套接字 加入到 read_fds 集合中 for (int i = 0; i < num; i++) { fds[i + 1].fd = client_fd[i]; fds[i + 1].events = POLLIN; // 要监听的事件:可读 fds[i + 1].revents = 0; // 初始化时是没有返回值的 } // poll() 监听 re = poll(fds, MAX_NUM, 2000); if (re > 0) { // 判断read_fds 集合中 是否有文件描述符就绪 // 1. 服务器就绪: 新的客户端来连接 // 2. 客户端就绪:表示客户端上有数据可读 // 服务器就绪 --> 去接受客户端的连接请求 if (fds[0].revents & POLLIN) { // 接受连接请求 accept socklen_t len = sizeof(client_addr[num]); int new_fd = accept(sockfd, (struct sockaddr *)&client_addr[num], &len); if (new_fd == -1) { perror("accept failed"); break; } printf("accept success\n"); printf("client ip = %s\n", inet_ntoa(client_addr[num].sin_addr)); // 把新的客户端的套接字保存 client_fd[num] = new_fd; num++; } else { // 客户端就绪 ---> 读取数据 for (int i = 0; i < num; i++) { // 判断客户端的套接字描述符是否可读 if (fds[i + 1].revents & POLLIN) { // 读取数据 char buf[128] = {0}; int r = recv(client_fd[i], buf, sizeof(buf), 0); if (r > 0) { printf("r = %d, buf = %s\n", r, buf); printf("client IP = %s\n", inet_ntoa(client_addr[i].sin_addr)); } if (buf[0] == '#') { // 客户端退出 close(client_fd[i]); } } } } } else if (re == 0) { // 超时 printf("超时 \n"); } else { // 出错 perror("select error"); break; } } //关闭套接字 close(sockfd); }
3.3 epoll
select 和 poll 的效率都不高
a. 因为这些文件描述符每次执行函数都会被拷贝两次
用户 ---> 内核
内核 ---> 用户
b. select 和 poll 每次都需要遍历所有的文件描述符才能确定哪些文件描述符是就绪的
epoll 工作方式:
epoll 是Linux特有的 IO多路复用机制,它使用一个内核事件表来管理和监听多个IO事件的就绪状态。应用程序将需要监听的文件描述符添加到内核事件表中,然后调用 epoll_wait 函数进行监听。当有文件描述符就绪时,epoll_wait 函数会返回,并告知哪些文件描述符已经准备好进行读取或写入操作
epoll 的接口非常简单,只有三个函数:
1. 创建 epoll 的句柄
NAME epoll_create, epoll_create1 - open an epoll file descriptor 打开一个epoll的文件描述符(句柄,实例) SYNOPSIS #include <sys/epoll.h> epoll_create:创建一个监听文件的集合,这个函数的返回值是一个文件描述符 int epoll_create(int size); size: size用来告诉内核这个监听的数量一共有多大 size现在已经被忽略了,但是指定的时候,需要>0 epoll本身占用一个文件描述符,使用完毕后需要关闭 返回值: 成功返回一个epoll的实例(本质就是一个文件描述符) 失败返回-1,同时errno被设置
创建 epoll 的实例是用来监听多个文件描述符的,那么要监听的文件描述符是如何加入到这个实例中去的呢?
2. 将需要监听的文件描述符加入epoll 句柄或者从句柄中删除文件描述符对于指定的文件描述符,只需加入一次即可,会记录到内核链表中,不像 select 和 poll 每次监听都需要重新加入
NAME epoll_ctl - control interface for an epoll descriptor SYNOPSIS #include <sys/epoll.h> int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event); epfd:epoll的实例,表示你要操作那一个epoll的文件描述符 op:具体对epoll实例进行什么操作 EPOLL_CTL_ADD:增加fd指定的文件描述符到epfd所表示的epoll实例中去 EPOLL_CTL_MOD:修改fd指定的文件描述符监听的事件 EPOLL_CTL_DEL: 删除fd指定的文件描述 fd:要操作(add,mod,del)的文件描述符 event:要监听fd的哪一些事件 typedef union epoll_data { void *ptr; int fd; uint32_t u32; uint64_t u64; } epoll_data_t; // 事件描述结构体 struct epoll_event { uint32_t events; // 要监听的事件,使用位域来实现的,不同的事件占用events中的不同的位 epoll_data_t data; // 用户自定义数据,可以记录自己的文件描述符等 }; 主要的事件有: EPOLLIN:监听的事件为可读事件 EPOLLOUT:监听的事件为可写事件 EPOLLRDHUP:监听流式套接字(tcp)对方是否已经关闭 EPOLLERR:监听事件是否出错 EPOLLET:Edge_Triggered 边缘触发,epoll的一个优势 这个标志,表示监听的文件的数据有变化时,才会报告事件 LT:对于读来说只要有数据,就会不断的报告就绪 ET:当数据发生改变的时候,才会报告一次就绪事件 返回值: 成功返回0, 失败返回-1,同时errno被设置
3. 等待事件发生,当 timeout 超时的时候,还没有发生,也会返回NAME epoll_wait, epoll_pwait - wait for an I/O event on an epoll file descriptor 等待epoll实例上面的IO事件 SYNOPSIS #include <sys/epoll.h> int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout); epfd:需要监听的epoll实例 events:指针,指向一个结构体数组,用来保存已经就绪的事件的信息的 maxevents:表示第二个参数指向的数组最多可以保存多少个就绪事件 timeout:超时时间,单位为毫秒 返回值: >0 已经就绪的文件描述符的个数 已经就绪的事件的信息是直接保存到events指向的数组中,struct epoll_event结构体中 有一个成员,可以保存用户数据,一般用来保存的是事件本身的文件描述符 当函数返回的时候,直接去操作事件结构体就可以了,不需要轮询所有的文件描述符 =0 超时了 <0 出错了,同时errno被设置
IO多路复用
利用 epoll 实现基于UDP的一对多的通信#include <stdio.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <string.h> #include <unistd.h> #include <stdlib.h> #include <poll.h> #define MAX_NUM 256 /* select_server.c IO多路复用 UDP Serve */ int main(int argc, char *argv[]) { // 1.创建套接字 socket int sockfd = socket(AF_INET, SOCK_STREAM, 0); if (sockfd == -1) { perror("socket failed"); return -1; } // 设置端口号重用 int on = 1; setsockopt(sockfd, SOL_SOCKET, SO_REUSEPORT, &on, sizeof(on)); // 2.绑定服务器的ip和端口 bind (服务器的ip和端口) struct sockaddr_in sAddr; sAddr.sin_family = AF_INET; // 协议族 sAddr.sin_port = htons(atoi(argv[2])); // 端口号(网络字节序) inet_aton(argv[1], &sAddr.sin_addr); // ip地址 int re = bind(sockfd, (struct sockaddr *)&sAddr, sizeof(sAddr)); if (re == -1) { perror("bind failed"); close(sockfd); return -1; } printf("bind success\n"); // ========== epoll() IO多路复用 =========================== // (1)创建一个epoll实例,用来监听其他的文件描述符的状态 int epfd = epoll_create(10); if (epfd == -1) { perror("epoll_create failed"); close(sockfd); return -1; } printf("epoll_create success! \n"); // (2)把要监听的文件描述符 加入到epoll实例中 struct epoll_event ev; ev.events = EPOLLIN | EPOLLET; // 要监听的事件:可读 | 边缘触发模式 ev.data.fd = sockfd; re = epoll_ctl(epfd, EPOLL_CTL_ADD, sockfd, &ev); if (re == -1) { perror("epoll_ctl failed"); close(sockfd); return -1; } printf("epoll_ctl success! \n"); struct sockaddr_in client_addr; socklen_t addrlen = sizeof(client_addr); while (1) { struct epoll_event ee[10]; // 保存已经就绪的事件信息的数组 // (3)等待监听事件的发生 int num = epoll_wait(epfd, ee, 10, 2000); if (num > 0) { // 判断是否已经可读 for (int i = 0; i < num; i++) { if(ee[i].events & EPOLLIN) { // 接收数据 char buf[128] = {0}; re = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr *)&client_addr, &addrlen); if (re > 0) { printf("buf = %s\n", buf ); } else { perror("recvfrom failed "); break; } } } } else if (re == 0) { // 超时 printf("超时 \n"); } else { // 出错 perror("epoll failed"); break; } } // 关闭套接字 close(sockfd); }
4. select / poll / epoll 比较
(1) select优点:
select 是最古老的IO复用的API,所以是支持跨平台的。而epoll是Linux内核独有的IO复用
缺点:1. 在发起select系统调用以及返回的时候,用户线程各发生了一次从用户态到内核态的切换内核态到用户 态的上下文切换开销,发生了两次上下文切换
2. 在发起select系统调用的时候以及返回时,用户线程需要将文件描述集合从用户空间拷贝到内核空间, 以及在内核进行修改之后,从内核空间拷贝到用户空间,发生了两次的文件描述符的拷贝
3. 在用户空间发起轮询被优化成在内核空间发起轮询,但是select 并不会告诉用户线程到底是那些socket 发生IO就绪事件,只是对其进行标记,用户线程任然需要遍历文件描述符集合去查找具体的IO就绪的 socket,事件复杂度认为O(n)
tips:大部分情况下,网络的连接并不活跃,如果select 监听大量的客户端连接,而只有少量连接活跃的情况下,用这种轮询的方式会随着连接数的增大,效率会越来越低
4. 内核会对原始的文件描述符进行修改,导致每次用户线程发起select 调用的时候,都需要重置文件描述符集合
tips:在创建监听集合时,可以保存一份备份
5. BitMap (位图)结构的文件描述符结合,长度默认为1024,所以只能监听0-1023的文件描述符 (可以修改默认值)
6. select 调用不是线程安全的
以上select的不足所产生的的性能开销会随着并发量的增大而现行增长
select 并不能解决C10k问题,只能解决约1000个左右的并发连接
(2) pollselect 中采用的文件描述符集合是采用的固定长度 (BitMap结构)的数组 fd_set,而poll换成了一个pollfd 结构的没有固定长度的数组,这样就解决了文件描述符的限制 (受系统文件描述符的限制)
1. poll只是改进了select 只能监听1024个文件描述符的数量限制,但是并没有在性能方面做出改进。和 select上本质并没有多大差别
2. 同样需要在内核空间和用户空间中对文件描述符集合进行轮询,查找出IO就绪的Socket 的时间复杂度依然为O(n)
3. 同样需要将包含大量文件描述符的集合整体在用户空间和内核空间之间来回复制,无论这些文件描述符是否就绪。他们的开销都会随着文件描述符数量的增加而线性增大
4. select,poll 在每次新增,删除需要监听的socket 时,都需要将整个新的socket集合全量传至内核
5. poll同样不适用高并发的场景。依然无法解决C10K问题
(3) epoll优点:
1. 监听的描述符没有上限
2. epoll_wait 每次只会返回Ready的描述符,不用完整遍历所有被监听的描述符
3. 监听的描述符被注册到 epoll 后会与epoll的描述符绑定,维护在内核,不主动通过epoll_ctl 执行删除不会自动被清理,所以每次执行epoll_wait后用户侧不用重新配置监听,内核 侧在epoll_wait 调用前后 也不会反复注册和拆除描述符的监听
4. 可以通过epoll_ctl 动态增减监听的描述符,即使有另一个线程已经在执行epoll_wait
5. epoll_ctl在注册监听的时候还能传递自定义的event_data,一般是传描述符
6. 即使没线程等在epoll_wait 上,内核因为知道所有被监听的描述符,所以在这些描述符Ready时候就 能做处理,等下次有线程调用epoll_wait时候直接返回。这也帮助epoll去实现 IO Edge Trigger,即 IO Ready时候Kernel 就标记描述符为Ready,之后在描述符被读空或写空前不再去监听它
7. 多个不同的线程能同时调用epoll_wait等在同一个epoll描述符上,有描述符Ready后它们就去执行
缺点:1. epoll_ctl 是个系统调用,每次修改监听事件,增加监听描述符的时候都是一次系统调用,没有批量操作的方法
2. 对于服务器上大量连上又断开的连接处理效率低,即accept()执行后生成一个新的描述符需要执行 epoll_ctl 去注册新Socket的监听,之后epoll_wait又是一次系统调用,如果Socket 立即断开了 epoll_wait会立即返回,又需要再用epoll_ctl 把它删掉