操作系统
设备交互
CPU 与 IO 设备交互过程
CPU 通过设备控制器(驱动?)与计算机外设进行交互。可以将控制器想象成编程语言中的接口,然后不同地计算机外设的控制器去实现这个接口,CPU 只需要调用接口而无需关注具体地实现,通过这种设计可以让 CPU 指令做得精简。
拓展:软件六大编程原则之一:依赖倒置原则:上层模块不应该依赖底层模块,它们都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。
中断
CPU 中断机制:键盘原理
DMA(Direct Memory Access)
将用户程序的数据源地址、数据目的地址以及数据长度一次性送给相应外设的 DMA 程序,从而减少 CPU 中断次数提高性能。
- 中断加 DMA 减少 CPU 开销
注意:DMA 只能完成内核与计算机外设之间的数据拷贝,内核态与用户态之间的数据拷贝还是需要 CPU 介入
系统调用
程序主要分为用户程序和操作系统程序两种,分别运行在用户空间和内核空间,为了安全以及保证硬件设备和操作系统的稳定性,只有操作系统程序才有权限访问硬件设备,所以应用程序只能通过系统调用/中断/异常三方式之一从用户态切换到内核态,经由操作系统程序完成与计算机外设的交互。
注:系统调用分类
- printf 的用户态与内核态切换动画演示
- 内核态 vs 用户态:通过内核态程序实现计算机资源的统一调度协调,从而让用户程序有条不紊地运行。
- Linux 如何实现系统调用
32bit 操作系统的系统调用也是通过发起 ENTER_KERNEL 调用,发起中断指令 0x80 来实现的。
运行在用户态的函数通过 CPU 中的寄存器将函数执行的必要信息(系统调用号(哪个系统调用函数)、函数参数)传递给系统调用中断程序
系统中断程序运行过程:
64bit 操作系统的系统调用则直接通过 syscall 指令来实现的
I/O
同步阻塞
在 IO 连接建立后,对应的线程会被阻塞,直到收到数据后,该阻塞状态才会被解除。
- 如果使用一个线程来监听连接,一个进程只能为一个客户提供服务;
- 如果为每个连接单独分配一个线程可以解决一个进程维护多个连接的目的,但线程调度、上下文切换以及其占用的栈空间都可能成为瓶颈。
代码示例
#include <unistd.h>
#include <sys/socket.h>
#include <string.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <iostream>
using namespace std;
/*
* 1. 创建套接字
* 2. 绑定地址信息
* 3. 进行监听--(告诉OS内核可以接收客户端发送的新请求了并且监听新连接的到来)
* 4. TCP三次握手建立连接----OS会自动进行,不需要我们进行控制
* 5. 接收新连接
* 6. 接收数据、发送数据
* 7. 关闭套接字
*/
int main()
{
//1. 创建套接字
int sockfd = socket(AF_INET,SOCK_STREAM,IPPROTO_TCP);
if(sockfd < 0)
{
cout << "TCP socket failed" << endl;
return 0;
}
//2.绑定地址信息
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port = htons(18989);
addr.sin_addr.s_addr = inet_addr("0.0.0.0");
int ret = bind(sockfd,(struct sockaddr *)&addr,sizeof(addr));
if(ret < 0)
{
cout << "bind failed" << endl;
return 0;
}
//3.进行侦听(告诉OS内核可以与客户端建立连接了)
//listen函数中blocklog参数,设定的是已完成三次握手并已建立连接的队列的容量
ret = listen(sockfd,1);
if(ret < 0)
{
cout << "listen failed" << endl;
return 0;
}
//4.接收新连接
struct sockaddr_in client_addr;
socklen_t socklen = sizeof(client_addr);
int new_sockfd = accept(sockfd,
(struct sockaddr *)&client_addr,&socklen);
if(new_sockfd < 0)
{
cout << "accept failed" << endl;
return 0;
}
printf("accept success: %s:%d, and new_sockfd is %d\n",
inet_ntoa(client_addr.sin_addr),ntohs(client_addr.sin_port),
new_sockfd);
//5.接收和发送数据
while(1)
{
//这里接收数据和发送数据是没有顺序而言的
//因为已经经过三次握手,客户端和服务端的连接以及建立成功了
char buf[1024] = {0};
ssize_t recv_size = recv(new_sockfd,buf,sizeof(buf)-1,0);
if(recv_size < 0)
{
cout << "recv failed" << endl;
continue;
}
else if(recv_size == 0)
{
cout << "Peer close" << endl;
close(new_sockfd);
close(sockfd);
return 0;
}
cout << "accrpt data is : " << buf << endl;
//发送数据
memset(buf,'\0',sizeof(buf));
sprintf(buf,"hello lient,i am server,i am %s:%d\n",
inet_ntoa(addr.sin_addr),ntohs(addr.sin_port));
ssize_t send_ret = send(new_sockfd,buf,strlen(buf),0);
if(send_ret < 0)
{
cout << "send failed" << endl;
continue;
}
}
close(sockfd);
return 0;
}
可以看出socket 编程服务端核心步骤为:
- bind:绑定端口
- accept:等待客户端连接
- rev:接收客户端请求
- send:将数据响应给客户端
资料
- 动画演示 BIO 原理:socket 结构、进程切换、用户态与内核态的切换。
- Socket 缓冲区
一个 socket,会带有两个缓冲区,一个用于发送,一个用于接收。因为这是个先进先出的结构,有时候也分别叫它们发送队列、接收队列。
同步非阻塞
I/O 是否阻塞,由这个连接对应的 fd 阻塞属性决定。
在 Linux 中,可以在创建 socket 时,直接将它设置为非阻塞模式 int socket (int __domain, int __type, int __protocol)
,将 __type
增加SOCK_NOBLOCK
就可以创建一个非阻塞的 socket,例如:int socket_fd = socket(AF_INET, SOCK_STREAM | SOCK_NONBLOCK, IPPROTO_TCP);
异步
Reactor 与 Proactor 为什么性能高?
先来看看阻塞 I/O,当用户程序执行 read ,线程会被阻塞,一直等到内核数据准备好,并把数据从内核缓冲区拷贝到应用程序的缓冲区中,当拷贝过程完成,read 才会返回。
注意:阻塞等待的是「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程。非阻塞的 read 请求在数据未准备好的情况下立即返回,可以继续往下执行,此时应用程序不断轮询内核,直到数据准备好,内核将数据拷贝到应用程序缓冲区,read 调用才可以获取到结果。
将数据从内核缓冲区拷贝到用户程序空间是一个同步过程。因此,无论 read 和 send 是阻塞 I/O,还是非阻塞 I/O 都是同步调用。
异步 I/O 在「内核数据准备好」和「数据从内核态拷贝到用户态」这两个过程都不用等待。 当我们发起 aio_read (异步 I/O) 之后,就立即返回,内核自动将数据从内核空间拷贝到用户空间,这个拷贝过程是内核自动完成的,和前面的同步操作不一样,应用程序并不需要主动发起拷贝动作。
资料
- 动画演示 NIO 原理
在 IO 连接建立后,对应的线程在未收到数据时不会被阻塞,程序员可以将该连接对应的 fd 用集合保存起来,后续逐次轮询,有数据时再从 fd 对应的连接中取出数据进行处理,达到一个线程可以处理多个网络连接的目的,从而提高效率。
多路复用
多路:指多个 Socket 连接,复用:使用同一个线程。
动图解释 select、poll、epoll 实现原理
select(man 2 select)
select 中定义的 fd_set 实际是一个 bitmap(默认长度为 1024 ,更改该长度需要修改内核代码然后重新编译),以文件描述符作为 bitmap 的索引设置值。内核会轮询集合中的 fd,当对应的 fd 的数据队列中有数据时,会将其对应的等待进程队列中的进程唤醒,并根据 fd 是否有数重新设置 bitmap 的值后从内核态重新返回到用户态,用户程序从 bitmap 有值的 fd 中读取数据。
总结:
使用:MySQL
其它
- 位图实现:通过 long 数组模拟而成,每个比特对应一个文件描述符数值。
通过在 Linux 上执行 man select 可以看到:
An fd_set is a fixed size buffer. Executing FD_CLR() or FD_SET() with a value of fd that is negative or is equal to or larger than FD_SETSIZE will result in undefined behavior. Moreover, POSIX requires fd to be a valid file descriptor.
FD_SETSIZE 在 sys/select.h 文件中的定义如下:
// include/uapi/linux/posix_types.h
#define __FD_SETSIZE 1024
typedef struct {
unsigned long fds_bits[__FD_SETSIZE / (8 * sizeof(long))];
} __kernel_fd_set;
可以看出 Linux 内核的宏限制了 fd_set 最多只支持1024。
注意:
- 在 Linux 中,文件描述符数值 0,1,2 已经被系统占用,分别表示为标准输入,标准输出和标准错误。
- select 不支持大于 1024 的文件描述符的监听。
- select 函数的参数 maxfd (最大文件描述)有什么作用?
select 使用 1024 比特位图监测最多 1024 个文件描述符,然而实际的情况很少会到达1024文件描述符上限,使用 maxfd 可以避免每次都轮询 1024 个文件描述符,从而提高轮询效率。maxfd 通常设置为已打开最大文件描述符 +1,目的是为了保证位图中每个文件描述符都被轮询到。 - 优缺点
- 优点:
- select 支持多种文件描述符类型,包括 socket、标准输入输出、管道、FIFO 等。
- select是跨平台的,可以在多种操作系统上使用。
- 缺点:
- select 的效率不高,每次调用 select 都需要将所有托管的文件描述符从用户态复制到内核态,托管的文件描述符中有一个文件描述符事件就绪后,也会将所有托管的文件符从内核空间拷贝到用户空间,这个过程比较耗时。
- select返回后需要遍历所有的文件描述符,找到就绪的文件描述符,这个过程也比较耗时。
- select支持的文件描述符数量有限,需要保证放入 select 监听的文件描述符值不大于 1024,否则程序会出现未知错误。
- select 为什么会有 1024 文件描述符限制?
- select 采用轮询的方式获取读,写,异常事件,如果需要轮询的文件描述符比较多的话,select 执行效率会非常低。
poll(man 2 poll)
下图有些错误:revents 不只是一个状态标识 1,其值为可用的事件(比如 POLLIN)
注释:
- 最常用的网络事件为 EPOLLIN 和 EPOLLOUT,EPOLLIN 对应为有 socket 缓冲区数据可读(当又收到了对端的一些数据,就会触发;或者作为服务端时有连接连过来),EPOLLOUT 对应 socket 缓冲区可写。
总结:
epoll(extend poll:man 2 epoll_create)
可以通过红黑树以 O(log(n)) 的复杂度找到该 fd 对应的 epitem
注意:
- 调用 epoll_ctl 函数将 fd 添加(EPOLL_CTL_ADD)到红黑树时,也会注册一个回调函数,该回调函数在该 fd 关注的事件发生时,会将其对应的 epitem 加入到数据就绪队列中。
红黑树排序规则 :
总结:
注意:
- 由于需要在内核空间查找红黑树,所以实际的时间复杂度为 O(log(n)),这里的 O(1) 指,通过 epoll_wait 返回到用户空间的 epoll_event 数组都是有事件发生的 fd,所以无需像 select 或 poll 那样,在用户空间遍历 fd 集合(时间复杂度为 O(n))判断 fd 是否就绪(select 通过 FD_ISET 函数,poll 通过类似 pollfd.revents & POLLIN 运算)。
- select、poll 在监听 fd 状态前,每次都需要全量的将需要监听的 fd 集合从用户态拷贝到内核态,在 fd 集合中有一个 fd 发生监听的事件时,也会全量的 fd 集合从内核态拷贝到用户态,这样是及其耗费资源与性能的。epoll 只有在调用 epoll_ctl 时,才会将 fd 从用户态拷贝到内核态,然后在内核态维护该 fd 集合构成的红黑树,在 fd 集合中有 fd 发生了监听的事件时,也只会返回这些发生了事件的 fd。
思考
- 在并发量不高时,select、poll 通过轮询 fd 集合来判断 fd 是否已经就绪的效率也并不低,所以此时仍然可考虑使用 select 和 poll,但如果在高并发量情况下(比如 C10K),就需要用 epoll 模型,它的速度更快,开销更小。
- 红黑树 vs 哈希表
linux 内核提供的哈希表是 hlist,即采用拉链法解决哈希碰撞的方法。hlist 进行哈希查找是 O(1) 是建立在哈希映射到各个桶的概率一致,并保证现有元素比桶个数小于负载因子 α 后,在有足够多元素时可以认为每一个桶中元素的个数服从 π(α) 即泊松分布。然而,在某些极端的情况下(或者说哈希安全性被打破的情况下),某些桶内包含了更多的元素,某些元素的查找时间会远高于其它的,并且单依靠拉链法是无法解决这种退化情况的。在删除某些元素时需要找到对应的节点,也就是说,在扩容/缩容和删除某些节点时,其操作时间可能是不可控的。相比之下,红黑树的强约束就显得可控许多了。
- 相比 select、poll 只支持水平触发(LT:Level Triggered),epoll 还支持边缘触发(ET:Eage Triggered),两种触发模式的区别是啥?
水平触发和边缘触发通过 epoll_wait 函数的响应体现,示例。
- LT:只要缓冲区有数据,epoll_wait 就会一直被触发,直到缓冲区为空;(有数据会连续触发)
- ET:只有所监听的事件状态改变或者有事件发生时,epoll_wait 才会被触发;(有数据只触发一次)
Nginx ,除了 listenfd(网络服务器中一般涉及两类 sockfd:用于监听是否有连接请求的 listenfd;用于传输数据的 socket(accept 返回的 acceptfd)) 用水平触发之外,其它都是用的边缘触发。
- 有一些解释是 listenfd 用水平触发,如果多个 client 同时连接进来,accept 一次只处理一个连接,为防止漏掉连接选择水平触发。
- 为什么 IO 多路复用要搭配非阻塞 IO?
- 为什么 IO 多路复用要搭配非阻塞 IO?
假如 socket 的读缓冲区中已有足够多的数据,需要调用三次 read 才能读取完。
- 非阻塞 I/O 的处理方式:循环的 read 或 accept,直到读完所有的数据(抛出 EWOULDBLOCK 异常)
- 阻塞 I/O 的处理方式:每次只能调用一次 read 或 accept,因为多路复用只会告诉你 fd 对应的 socket 可读了,但不会告诉你有多少的数据可读,所以在 handle_read/handle_accept 中只能 read/accept 一次,你无法知道下一次 read/accept 会不会发生阻塞。所以只能等 ioloop 的第二次循环,ioloop 告诉你 fd 可用后再继续调用 handle_read/handle_accept处理,然后再循环第三次。
所以你会发现,阻塞方式稍不注意就会阻塞整个进程:多路复用只能知道有数据可读,但是不知道有多少数据可读,使用阻塞io,读了一次就不知道是否应该继续读,如果继续读,读到第四次就阻塞了。
零拷贝
资料
-
动画演示-传统网络读写过程:四次上下文切换,四次数据拷贝(两次 DMA 拷贝 + 两次 CPU 拷贝)
动画图解 socket 缓冲区那些事:每个 socket 在内核态都有对应的读写缓冲区(readbuf、writebuf)
-
动画演示-MMAP + write 实现“零拷贝”:通过 mmap 建立了 PageCache 到用户进程的虚拟地址空间的映射,即用户进程可以直接通过 mmap 返回的 fd 直接读取 pagecache 对应的物理内存空间(四次上下文切换 + 三次数据拷贝(两次 DMA 拷贝 + 一次 CPU 拷贝))
-
动画演示-sendfile 实现零拷贝:二次上下文切换 + 三次数据拷贝(两次 DMA 拷贝 + 一次 CPU 拷贝)
注意:linux kernel 2.1 开始支持 sendfile
- 动画演示-sendfile + DMA gather copy 实现零拷贝:sendfile 利用 DMA 将文件内容拷贝到内核缓冲区(PageCache),然后将带有文件位置和长度信息的缓冲区描述添加到 socket 缓冲区(这一步不会将内核中的数据拷贝到 socket 缓冲区中),DMA 会直接将内核缓冲区的数据拷贝到网卡设备通过网络发送出去。(两次上下文切换 + 两次 DMA 拷贝)
注意:linux kernel 2.4 才支持这种拷贝方式,并且需要硬件以及驱动程序的支持
- 动画演示-splice + DMA 实现零拷贝: splice 通过在 PageCache 和 Socket 缓冲区之间建立一个环型管道,这样DMA 可以直接通过 socket 缓冲区将 PageCache 中的数据拷贝到网卡设备(两次上下文切换 + 两次 DMA 拷贝)
注意:linux kernel 2.6.17 才引入 splice。splice 利用 Linux 的管道缓冲区机制, 所以要求绑定的两端的描述中至少有一个为管道设备。