一、基础知识
1. 用户空间/内核空间
在操作系统启动的时候会将内存分为两块,用户空间(User space)和内核空间(Kernel space)。用户空间是为应用程序和用户进程保留的区域;内核空间是用于存放操作系统的核心代码、数据结构和设备驱动程序等。将应用程序和操作系统核心分割开来,防止应用程序对系统资源的滥用或错误操作对整个系统的影响。
用户空间和内核空间之间存在一种特殊的边界,称为用户态和内核态的切换。当应用程序需要执行特权操作或请求系统服务时,它必须通过系统调用(System call)将控制权转移到内核空间。内核空间执行相应的操作后,将结果返回给用户空间,并将控制权交还给应用程序。
比如写了一个Java程序需要读取一个文件中的内容,在用户空间的应用是无法直接操作该文件的,需要通过系统调用CPU将用户态到内核态对文件进行操作,操作后将读出的结果返回给用户空间,这个时候CPU将内核态转为用户态,Java再对返回的结果进行处理。
2. 文件描述符
在Linux系统中万物皆是文件,一个标准文件,一个目录,甚至一个Socket套接字,每个打开的文件(包括标准输入、标准输出、网络连接等)都可以抽象出一个唯一的非负整数作为文件描述符(file descriptor,下文都用fd表示),应用程序可以使用文件描述符来进行文件的读取、写入、关闭等操作。
3. 中断
中断是Linux中很重要的机制,他是操作系统内核夺回CPU使用权的唯一途径,如果没有中断机制,一旦应用程序获取了CPU的使用权,则CPU就会一直运行该应用程序。中断主要分为两种,硬中断和软中断。
硬中断(Hardware Interrupt)是由计算机硬件设备发出的中断信号,用于通知处理器发生了某种事件,例如外部设备的输入/输出请求、时钟中断(进程的时间片轮转调度,实现并发的方式)等。
软中断(Software Interrupt)是由计算机软件发出的中断信号,用于请求操作系统执行特定的服务(系统调用CPU从用户态变为内核态)、应用程序出现异常(比如除数为0)、打断指令(应用程序直接调用内核空间的信息)等。
二、网络编程中的几种I/O
1. 传统的 BIO 编程
BIO代表的是"Blocking I/O",即阻塞式I/O。在这个模型中,当一个I/O操作发生时,程序会被阻塞,直到该操作完成或者发生异常才会打断阻塞,这就意味着程序在等待I/O操作完成期间无法执行其他任务。下面是一段BIO的伪代码:
- 服务端
bash
int fd5 = socket(localhost, SOCK_STREAM, 0);
bind(fd5, server_address, sizeof(server_address));
listen(fd5, 5);
while (1) {
int fd7 = accept(fd5, client_address, client_address_size);
read(fd7, buffer, BUFFER_SIZE);
logic(buff);
}
- 客户端
bash
int fd6 = socket(localhost, SOCK_STREAM, 0);
connect(fd6, (struct sockaddr *)&server_address, sizeof(server_address));
write(fd6, buffer, BUFFER_SIZE);
从伪代码中可以得到一个简单BIO的交互流程如下:
- 服务端通过系统调用socket获得一个服务端套接字,套接字对应的文件描述符为fd5。
- 依次通过bind、listen后进入监听状态并通过调用accept接受客户端的连接,这个时候服务端将阻塞 在这里等待客户端 调用connect方法进行连接。
- 假如这个时候有一个客户端进行连接了,他会使用调用socket获得一个新的客服端套接字,对应的文件描述符为fd6。
- 客户端 调用connect,TCP进行三次握手成功 后,则服务端获得一个新的套接字,对应的文件描述符为fd7,后续的读写操作发生在fd6和fd7之间,并打断 accept的阻塞。
- 服务端 的accept的阻塞打断后,系统调用read方法,read的方法调用后系统将阻塞等待客户端的write到来。
- 当客户端 通过write向服务端 发送数据后,服务端拿到对应的数据,并将read的阻塞打断。
在上面的例子中只模拟了一个客户端 对服务器进行连接和读写,假如有多个客户端进行连接,则服务端 需要依次处理 客户端的连接,客户端信息的读写,服务端的处理会因为阻塞变得很慢,甚至如果中间有个客户端连接进来之后不进行数据的读写,则服务器会一直阻塞在等待读写的过程中。
解决这个问题的方法很明确,就是把服务端的等待连接和等待数据读写的两个阻塞分到不同的线程 里面里面去完成,让两个阻塞不影响即可。每次客户端连接后在主线程中创建一个新的线程去处理调用read的阻塞,这样就得到了一个多线程的BIO模型。
虽然解决了问题,但是这个方案在客户端连接量比较多的情况下会在服务端产生大量的线程,这些线程不但占用了大量的内存空间,也会导致CPU会花更多的时间在线程间的上下文切换上。
2. 非阻塞的NIO
BIO 因为系统调用accept和read的阻塞 造成多个客户端 连接存在问题,虽然可以通过多线程解决问题,但是多线程又带来了线程过多 的问题,那如果accept和read不阻塞的话,就可以在同一个线程中同时处理多个客户端的连接和数据读写,于是内核就给文件描述符加上了non-blocking的属性,可以通过fcntl或者socket等系统调用函数进行操作。
当文件描述符为non-blocking时,服务端在调用accept或者read系统调用函数时,内核不会将操作阻塞,而是立即返回值,如果在调用时有数据就位,则将数据返回给用户空间,如果没有数据就位,则返回错误信息给用户空间。下面是一段NIO的伪代码:
- 服务端
bash
int[]fd_set;
int fd5 = socket(localhost, SOCK_STREAM, 0);
int flags = fcntl(fd5, F_GETFL, 0);
fcntl(fd5, F_SETFL, flags | O_NONBLOCK);
bind(fd5, server_address, sizeof(server_address));
listen(fd5, 5);
while (1) {
int fd7 = accept(fd5, client_address, client_address_size);
if(fd7>=0){
int flags = fcntl(fd7, F_GETFL, 0);
fcntl(fd7, F_SETFL, flags | O_NONBLOCK);
fd_set.add(fd7);
}
for(fd:fd_set){
int flags = read(fd, buffer, BUFFER_SIZE);
if(flags>0){
logic(buff);
}
}
}
通过不停的轮询去获取连接的客户端,当客户端连接成功后,系统调用accept的返回值则为套接字的fd,并将fd放入一个全局的fd_set中,再不断轮询去系统调用read。在整个通信中,连接的建立和数据传输是分开的。
通过上图和伪代码可以看出,数据的读取由用户态发起 ,如果内核的缓冲区中没有数据就会返回错误,如果客户端通过网卡将数据写到内核的缓冲区中,当用户态 再次发起read时,则将数据拷贝到用户空间,拷贝结束后,应用程序则可以在内存空间中进行数据处理。
解决了BIO的线程问题,但是普通的NIO在处理时还存在两个问题:
- 现在有100个客户端连接上之后,假设这100个没有一个用户进行数据的传输,就要一直去调用100次的系统调用,就会存在200次的用户态内核态上下文切换,但是这个时候无事发生,相当于在空跑。
- 客户端的数量再次增加,增加到1000个,1000个中有一个用户发生了数据的传输,但是他是最后一个,前面要经历999次的系统调用才能拿到数据,效率会很慢。
针对上面普通的NIO存在的问题引入了多路复用的概念
- 多路:指的是多个socket网络连接也就是多客户端连接;
- 复用:指的是复用一个线程、使用一个线程来检查多个文件描述符的就绪状态;
- 现在主要用的多路复用有三种技术:select,poll,epoll。
3. select的多路复用
select多路复用就像他的名字一样,应用程序将需要进行数据传输的fd一次性 传给内核,在内核中进行轮询然后select出对应的有数据就绪的fd给用户空。在内核中提供了一整套的select相关的函数和宏:
- int select (int n, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);
nfds:监控的文件描述符集里最大fd加1
readfds,writefds,exceptfds 分别对应需要检测的可读文件描述符集合,可写文件描述符集合,异常文件描述符集合。其中fd_set是一种特殊的结构,其实是一种位图,每一位代表着一个fd,默认都是0,如果有就绪的信息则改为1
timeout:用来设置 select 的等待时间。通常有以下三种设置方式:
NULL:阻塞式等待,select将一直被阻塞,直到某个文件描述符上发送了事件;
0:非阻塞式等待,调用select后检测文件描述符的状态,然后立即返回;
特定的时间值:阻塞式等待一定时间,若期间没有事件发生,select将超时返回;
- int FD_ZERO(fd_set *fdset); 一个fd_set类型变量的所有位都设为0
- int FD_CLR(int fd, fd_set *fdset); 清除某个位时可以使用
- int FD_SET(int fd, fd_set *fd_set); 设置变量的某个位置位
- int FD_ISSET(int fd, fd_set *fdset); 测试某个位是否被置位
下面是一段select多路复用的伪代码:
bash
// 省去了accept的操作
fd_set read_fds;
FD_ZERO(&read_fds);
FD_SET(fd1, &read_fds);
FD_SET(fd2, &read_fds);
FD_SET(fd3, &read_fds);
FD_SET(fd4, &read_fds);
FD_SET(fd5, &read_fds);
max_fd = fd5;
while (1) {
int ready_fds = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
if(ready_fds>0){
if (FD_ISSET(fd1, &read_fds)) {
char buffer[1024];
read(fd1, buffer, sizeof(buffer));
}
if (FD_ISSET(fd2, &read_fds)) {
char buffer[1024];
read(fd2, buffer, sizeof(buffer));
}
....
}
// 清空文件描述符集合,以便下一次select调用
FD_ZERO(&read_fds);
// 重新设置需要监视的文件描述符
FD_SET(fd1, &read_fds);
FD_SET(fd2, &read_fds);
FD_SET(fd3, &read_fds);
FD_SET(fd4, &read_fds);
FD_SET(fd5, &read_fds);
// 获取最大文件描述符
max_fd = fd5; // 假设fd5是最大的文件描述符
}
和普通的NIO相比,select的多路复用不用轮询去系统调用,而且将轮询交给了内核态,这样减少了上下文切换,但是还是有以下几个问题:
1、如果客户端数量增大在从用户空间将fd_set拷贝到内核空间时候将会有很大的开销。
2、调用select函数后返回值只是就绪的个数,所以数据到了用户态,应用程序还需要自己去便利fd_set拿出就绪的fd;
4. poll的多路复用
poll也是一种轮询的多路复用,和select的用法基本一样,他的函数如下:
bash
int poll(struct pollfd *fds, nfds_t nfds, int timeout);
// pollfd结构
struct pollfd {
int fd; /* 文件描述符 */
short events; /* 监听的事件 */
short revents; /* 监听事件中满足条件返回的事件 */
}
和select函数相比,poll函数的参数更少,将监听不同事件的信息放在了一起,然后在pollfd这个结构中定义监听事件,在定义pollfd时revents不赋值,内核在轮询的时候监听到events对应的事件时会将revents进行复制。
poll函数在调用时也需要将fds从用户空间拷贝到内核空间并且返回值也是就绪的个数,所以并没有解决select存在的问题,于是性能更高的epoll就诞生了。
5.epoll的多路复用
poll虽然做了一些改进,但是还是有文件描述符在用户态和内核态拷贝以及需要去轮询的问题,epoll解决了这个问题,下面是epoll相关的函数:
bash
int epoll_create(int size);
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
int epoll_wait:(int epfd, struct epoll_event *events,int maxevents, int timeout);
epoll_create:在内核态中创建一个eventpoll对象,对于用户空间来说,eventpoll相当于一个黑盒,这个是用来装连接的fd的池子;
epoll_ctl:eventpoll这个大池子创建好之后,需要使用epoll_ctl将fd添加到这个池子中,除了添加,还可以进行修改,删除,这样就不需要把全量的fd进行用户态和内核态的拷贝了,同时还可以使用epoll_event指定epoll监听fd的行为;
epoll_wait:等待内核返回的可读写事件,-1表示阻塞,0表示不阻塞,>0则表示没有检测到事件发生时最多等待的时间
epoll相关操作的伪代码
bash
server_fd = socket(AF_INET, SOCK_STREAM, 0);
bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr);
listen(server_fd, 5);
epoll_fd = epoll_create(10);
event.events = EPOLLIN;
event.data.fd = server_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_fd, &event);
while (1) {
event_count = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
for (i = 0; i < event_count; i++) {
if (events[i].data.fd == server_fd) {
client_fd = accept(server_fd, (struct sockaddr *)&client_addr, &client_addr_len);
event.events = EPOLLIN;
event.data.fd = client_fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_fd, &event)
} else {
ssize_t bytes_read = read(events[i].data.fd, buffer, BUFFER_SIZE);
logic(buffer);
}
}
}
epoll主要的流程如下图:
epoll中用到的两个结构体
bash
struct eventpoll {
//sys_epoll_wait用到的等待队列
wait_queue_head_t wq;
//接收就绪的描述符都会放到这里
struct list_head rdllist;
//每个epoll对象中都有一颗红黑树
struct rb_root rbr;
......
}
bash
struct epitem {
//红黑树节点
struct rb_node rbn;
//socket文件描述符信息
struct epoll_filefd ffd;
//所归属的 eventpoll 对象
struct eventpoll *ep;
//等待队列
struct list_head pwqlist;
}
epoll在为了大量数据拷贝的问题提供了epoll_create直接在内核空间中开辟了eventpoll的空间,用户态可以通过epoll_ctl进行该空间中fd的增删改,同时使用红黑树的数据结构将内核中遍历fd的成本从o(n)降到了o(logn);使用epoll_wait将select/poll返回全部fd变成了只返回就绪fd,性能大大加快。
尽管epoll很强大,但是对系统的支持不是很好,在系统不支持epoll时,应用还会才用select/poll的方式或者其他的方式进行多路复用。
三、总结
最后用一个例子进行总结,在一节课堂中,服务端比作收作业的老师,客户端比作交作业的同学
BIO: 老师先收A同学的作业,再去收B,C同学的作业,如果A同学一直做不完就不会去收B,C老师的试卷,虽然可以加几个老师去同时收作业,但是这样开销肯定会变大
NIO: 老师这个时候不会因为A同学作业写不完而阻塞,而是继续去收一下同学的作业
select/poll: 同学写完作业会举手,但是老师不知道是谁举的手,于是就把所有的同学轮询一遍
epoll: 同学写完了会举手,并且老师清楚的知道是哪个同学举手的,老师可以直接去收作业
个人学习记录,欢迎指出不足和错误的地方,评论区大家一起交流,一起学习进步😁😁😁