I/O多路复用
一次\(HTTP\)请求
考虑一个简单的问题,我们的客户端是如何向服务端发起一次\(HTTP\)请求的。
抓一个包来看看真相:
这是一次由本机发向www.baidu.com的一次http的GET请求。
前三个报文是TCP三次握手,后四三个报文是TCP四次挥手。
中间则是由本机先向百度服务器发送一个HTTP请求报文。
第一行是请求行,组成是 方法+URL+版本+"\r\n"
第二到四行是请求头,包含各种信息。
之后的是实体部分(截图没有实体部分)
百度服务器会向我们发送一个TCP的ACK报文,保证确实收到了请求。
接着百度服务器向我们发送一个HTTP响应报文。
第一行是响应行,组成是 版本+状态码+状态码代表的信息+\r\n
之后是响应头,包含各种信息,值得一提是Connection头代表的长连接Keep-Alive和短链接Close。
最后是响应实体,也就是网页的HTML。
然后我们会发送一个TCP的ACK报文,保证确实收到了请求。
这样我们大概理解了,一次HTTP请求在计算机网络层面发生了什么。
但是,我们还可以理解的更深。
利用\(socket\)进行\(HTTP\)通信
什么是\(socket\)?
所谓套接字(Socket),就是对网络中不同主机上的应用进程之间进行双向通信的端点的抽象。一个套接字就是网络上进程通信的一端,提供了应用层进程利用网络协议交换数据的机制。从所处的地位来讲,套接字上联应用进程,下联网络协议栈,是应用程序通过网络协议进行通信的接口,是应用程序与网络协议栈进行交互的接口。
简单来说,socket是用来通信的端点,他是网络进程的抽象。
那么对于我们的服务端和客户端我们该怎么利用socket进行通信呢?
首先,作为服务端,我们需要一个socket来监听和等待客户端的请求。那么我们的服务端需要先将这个socket和和我们的ip地址进行绑定。也就是bind函数。接着我们使用listen函数让服务端socket进入监听状态,此时服务端socket从Closed状态转换成Listen状态。此时服务端已经可以等待客户端发起请求了,所以服务端调用accept函数。这个函数将会阻塞服务端,直到有客户端发起请求。
现在把目光转向客户端,客户端建立socket,接着使用connect函数向服务端发起请求。建立连接之后,客户端写入信息,服务端读取信息,信息处理完了之后,服务端读取信息,客户端写入信息(连接建立之后的过程实际上就是HTTP请求的过程)。
$ More~Client ?$
在刚刚的流程中我们不难发现,服务端只能进行一对一的通信,这太垃圾了(好吧我从前写过一个一对一的服务器)。出于所以我们需要进行一对多的通信。
不难想到使用多进程或者多线程来实现这件事情。
多进程
在多进程模型中,我们开启accept函数之后,当有客户端向我们发起请求,我们选择fork一个子进程来处理这次通信。但是无论是进程的上下文切换还是产生的僵尸进程都会消耗系统资源。PS:为了避免僵尸进程一定要注意子进程的回收。
多线程
多进程的上下文切换耗费太大,我们来看看多线程模型是怎么处理的。
相比于创建进程时需要拷贝所有的资源,创建线程时只需要在栈空间分配一部分。在进行上下文的时候,由于线程的大部分资源都是共享的,所以线程的上下文切换的花费也更小。
多线程模型有很多,比较知名的像是one loop per thread+线程池。
\(IO\)多路复用
聊聊其他的
同步和异步:指的是用户和内核的交互方式。
同步:调用某个东西时间,调用方得等待这个调用返回结果才能继续往后执行
异步: 调用方不会理解得到结果,而是在调用发出后调用者可用继续执行后续操作,被调用者通过状体来通知调用者,或者通过回掉函数来处理这个调用
阻塞和非阻塞:强调的是程序在等待调用结果(消息,返回值)时的状态。
阻塞:调用者调用了某个函数,等待这个函数返回,期间什么也不做,不停的检查这个函数有没有返回,必须等这个函数返回后才能进行下一步动作。
非阻塞:非阻塞等待,每隔一段时间就去检查IO事件是否就绪。没有就绪就可以做其他事情。
常见的IO模型:
- 同步阻塞IO:传统IO模型,默认创建的socket是阻塞的。
- 同步非阻塞IO:非阻塞IO要求socket被设置为NONBLOCK。
- IO多路复用:同时对一组文件描述符处理。
- 信号驱动IO:利用信号机制,让内核告知程序文件描述符相关时间
- 异步IO:类似与上面的
\(IO\)多路复用
I/O多路复用,I/O就是指的我们网络I/O,多路指多个TCP连接(或多个Channel),复用指复用一个或少量线程。
在linux中,关于多路复用的使用,有三种不同的API,select、poll和epoll。
在MaxOS/FreeBSD中存在kqueue。
在Windows/Solaris使用IOCP。
Select
将所有的socket放在文件描述符集合中,然后调用select函数将文件描述符集合拷贝到内核中,内核遍历是否有网络事件产生,并且标记对应的Socket,再将文件描述符拷贝到用户态,在进行一次遍历,找到对应的socket。
select使用固定大小的BitsMap通常的是1024。我们可以提高这个上限,但是随着上限的提高,性能也会有所影响。
Poll
整体逻辑和select相同,但使用链表来实现,突破了最大连接数的限制。
Epoll
Epoll和Select/Poll相比简直就是高科技打冷兵器了。
在介绍Epoll原理之前我们来看看epoll有哪些重要函数:
int epoll_create(int size);
创建epoll句柄,size就是需要监听的文件数目。
int epoll_ctl(int epfd,int op,int fd,struct epoll_event *event);
第一个参数是epoll的文件描述符,
第二个参数表示使用的动作,由三个宏来表示:
- EPOLL_CTL_ADD //注册新的fd进入epfd
- EPOLL_CTL_MOD //修改fd
- EPOLL_CTL_DEL //在epfd中删除fd
第三个参数表示需要监听的fd
第四个参数表示内核监听的事件本身。
typedef union epoll_data{
void *ptr;
int fd;
__uint32_t u32;
__uint64_t u64;
}epoll_data_t;
struct epoll_event{
__uint32_t events;
epoll_data_t data;
};
events表示事件形式,由几个宏构成:
- EPOLLIN //文件可读
- EPOLLOUT //文件可写
- EPOLLPRI //文件有紧急的数据可读
- EPOLLERR //文件发生错误
- EPOLLHUP //文件被挂起
- EPOLLET //将EPOLL设置为ET,默认LT
- EPOLLONESHOT //只监听一次
int epoll_wait(int epfd,struct epoll_event *events ,int maxevents,int timeout);
第一个参数epfd表示我们创建的epfd,
第二个参数events用来从内核得到事件的集合,
第三个参数maxevents表示集合大小,
第四个参数timeout表示超时时间。
返回需要处理的事件数目,0表示已经超时。
Epoll原理
Epoll使用了两个数据结构来实现高效的IO多路复用。
在epoll_creat时,内核态会建立一颗红黑树用来存储epoll_ctl传来的socket,还会建立一个list链表,存储准备就需的事件。
在epoll_wait时,观察list链表中是否有数据,没有则进行sleep直到超时或者有数据,此时返回。
在epoll_ctl时,第一我们需要将socket放在epoll的红黑树上,第二我们还会给内核中断处理程序注册一个回调函数,这个回调函数表示当句柄的中断到了,他就放到准备就绪的list链表中。
ET模式和LT模式
edge-triggered (边缘触发)和 level-triggered (水平触发)。
看到网上有大佬翻译为状态变化通知和状态持续通知。
ET:这种模式下,只有当文件描述符变化为就绪态的时候,内核态会告诉你这件事,之后epoll不会发送更多的就绪通知。
LT:这种模式下,文件就绪时,内核会不断向你发送通知。
简单来说,只要就绪链表中有事件,LT模式下的epoll就会不断发送通知。
PS:ET要和非阻塞fd一起使用,因为ET一次事件只使用一次,对于可读事件,需要一直read fd直到fd被读完。