一、基础概念
1. socket
socket也称作“套接字”,用于描述IP地址和端口,是一个通信链路的描述符。应用程序通常通过“套接字”向对端发出请求或者应答网络请求。
socket是连接运行在网络上的两个程序之间的通信端点。通信的两端都有socket,它是一个通道,数据在两个socket之间进行传输。socket把复杂的TCP/IP协议族或者UDP/IP协议族隐藏在socket接口后面,对程序员来说,只要用好socket相关的函数,就可以完成数据通信。
2. FD:file descriptor**
Linux 系统中,把一切都看做是文件(一切皆文件),当进程打开现有文件或创建新文件时,内核向进程 返回一个文件描述符,文件描述符就是内核为了高效管理已被打开的文件所创建的索引,用来指向被打开的文 件,所有执行I/O操作的系统调用都会通过文件描述符。
一个进程能够同时打开多个文件,对应需要多个文件描述符,所以需要用一个文件描述符表对文件描述符 进行管理;通常默认大小为1024,也即能容纳1024个文件描述符;
在 soket 通信中,当我们调用内核函数创建 socket 后,内核返回给我们的是 socket 对应的文件描述符( fd),所以我们对 socket 的操作基本都是通过 fd 来进行。这个文件描述符 fd 可能是就绪的状态即大于 1 的整 数,也可能是未就绪的状态-1。就绪状态表示客户端或服务端发送的全部数据通过网卡进入到内核缓冲区buf, 这时调用 系统read( )就可以将数据从内核区拷贝到用户缓冲区。
见阻塞 IO 图。
3. 内核态和用户态
线程是操作系统调度CPU的最小单元,在一个进程里可以创建多个线程,这些线程都拥有各自的计数器、 堆栈和局部变量等属性,并且能够访问共享的内存变量。处理器在这些线程上高速切换,让使用者感觉到这些 线程在同时执行。线程的实现可以分为两类:用户级线程,内核线线程。(java线程就是内核级线程)
虚拟内存被操作系统划分成两块:内核空间和用户空间,内核空间是内核代码运行的地方,用户空间是用 户程序代码运行的地方。当进程运行在内核空间时就处于内核态,当进程运行在用户空间时就处于用户态,为 了安全,它们是隔离的,即使用户的程序崩溃了,内核也不受影响。说起这个概念就是因为线程上下文切换的 概念。虽然线程上下文切换比进程切换成本要低但是,线程切换也是很影响性能的。线程上下文切换就涉及用 户态到内核态的转换。
二、 IO 模型分类
服务器端编程经常需要构造高性能的IO模型,
1. 常见的IO模型
-
同步阻塞IO(Blocking IO):即传统的IO模型。
-
同步非阻塞IO(Non-blocking IO):默认创建的socket都是阻塞的,非阻塞IO要求socket被设置为NONBLOCK。
-
IO多路复用(IO Multiplexing):即经典的Reactor模式(并非23种设计模式之一),有时也称为异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型。epoll有两种工作方式,一种是LT模式(level trigger,水平触发),事件驱动,内核告诉你一个文件描述符是否就绪,然后你可以对就绪的文件描述符进行IO操作。一种是ET模式(edge trigger,边缘触发)。默认情况下是LT模式。
-
异步IO(Asynchronous IO):即经典的Proactor设计模式,也称为异步非阻塞IO。
2. 阻塞和非阻塞
阻塞和非阻塞的概念描述的是用户线程调用内核IO操作的方式,主体是IO:
阻塞是指IO操作需要彻底完成后才返回到用户空间;
而非阻塞是指IO操作被调用后立即返回给用户一个状态值,无需等到IO操作彻底完成。
阻塞非阻塞的主体是IO。
3. 同步和异步
同步和异步的概念描述的是用户线程与内核的交互方式,主体是线程:
同步是指用户线程发起IO请求后需要等待或者轮询内核IO操作完成后才能继续执行;
而异步是指用户线程发起IO请求后仍继续执行,当内核IO操作完成后通知用户线程,或者调用用户线程注册的回调函数。
同步异步的主体是用户线程。
三、 阻塞IO
为了方便理解,以下所有代码都是伪代码,知道其表达的意思即可。
服务端为了处理客户端的连接和请求的数据,写了如下代码。
服务端 客户端
listenfd = socket(); 打开一个网络通信端口
bind(listenfd); 绑定
listen(listenfd); 监听
while(1) { fd = socket();
connfd = accept(listenfd); 阻塞建立连接 connect(fd);
write(fd,buf);
int n = read(connfd, buf); 阻塞读数据 closed(fd);
doSomeThing(buf); 利用读到的数据做些什么
close(connfd); 关闭连接,循环等待下一个连接
}
服务端的线程阻塞在了两个地方,一个是 accept 函数,一个是 read 函数。
read函数包括两个阶段:
这就是传统的阻塞 IO。如果这个连接的客户端一直不发数据,那么服务端线程将会一直阻塞在 read 函数上不返回,也无法接受其他客户端连接。
四、非阻塞 IO
为了解决上面的问题,我们需要对 accept函数和 read 函数进行改造。
1、针对 read 函数造成的阻塞
这个 read 函数的效果是,如果没有数据到达内核缓冲区时,即第一阶段未完成,立刻返回一个错误值-1,而不是阻塞地等待。
操作系统提供了这样的功能,只需要在调用 read 前,将文件描述符设置为非阻塞即可。
服务端
listenfd = socket(); 打开一个网络通信端口
bind(listenfd); 绑定
listen(listenfd); 监听
while(1) {
connfd = accept(listenfd); 阻塞建立连接
fcntl(connfd, F_SETFL, O_NONBLOCK); 将文件描述符设置为非阻塞
int n = read(connfd, buffer); 如果 fd 未就绪,调用 read 会立即返回-1,处理下 一个连接
doSomeThing(buf);
close(connfd);
}
如果 fd 未就绪,调用 read 会立即返回-1,如果就绪,就会阻塞式的 read。
2、针对 accept函数造成的阻塞(IO 多路复用)
有一种聪明的办法是,每次都创建一个新的进程或线程,去调用 read 函数,并做业务处理。
while(1) {
connfd = accept(listenfd); // 阻塞建立连接
pthread_create(doWork); // 创建一个新的线程
}
void doWork() {
int n = read(connfd, buf); // 阻塞读数据
doSomeThing(buf); // 利用读到的数据做些什么
close(connfd); // 关闭连接,循环等待下一个连接
}
这样,当给一个客户端建立好连接后,就可以立刻等待新的客户端连接,而不用阻塞在原客户端的 read 请求上。不过,这不叫非阻塞 IO,只不过用了多线程的手段使得主线程没有卡在 read 函数上不往下走罢了。操作系统为我们提供的 read 函数仍然是阻塞的。
为每个客户端创建一个线程,服务器端的线程资源很容易被耗光。
当然还有个聪明的办法,我们可以每 accept 一个客户端连接后,将这个文件描述符(connfd)放到一个数组里。然后弄一个新的线程去不断遍历这个数组,调用每一个元素的非阻塞 read 方法。
服务端
listenfd = socket(); 打开一个网络通信端口
bind(listenfd); 绑定
listen(listenfd); 监听
fdlist;
while(1) {
connfd = accept(listenfd); 阻塞建立连接
fcntl(connfd, F_SETFL, O_NONBLOCK); 将文件描述符设置为非阻塞
fdlist.add(connfd);
}
新线程去处理
while(1) {
for(fd <-- fdlist) {
if(read(fd) != -1) {
doSomeThing();
}
close(fd);
//移除此 fd
}
}
这样,我们就成功用一个线程处理了多个客户端连接。
但这和我们用多线程去将阻塞 IO 改造,看起来是 一样的,这种遍历方式也是我们用户层的小把戏,每次遍 历遇到 read 返回 -1 时仍然是一次浪费资源的系统调用。
使用 while 循环不断地做系统调用,是不合理的。每次只传给 read 函数一个文件描述符,传一次调用一次。
我们每次传给 read 函数一批文件描述符到内核,由内核层去遍历,这个问题才能真正解决。
2.1 select 模型
select 是操作系统提供的系统调用函数,通过它,我们可以把一个文件描述符的数组发给操作系统, 让 操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理。不过,当 select 函数返回后,用 户依然需要遍历刚刚提交给操作系统的 list。只不过,操作系统会将准备就绪的文件描述符做上标识,用户 层将不会再有无意义的系统调用开销。
服务端
listenfd = socket(); 打开一个网络通信端口
bind(listenfd); 绑定
listen(listenfd); 监听
fdlist;
首先一个线程不断接受客户端连接,并把 socket 文件描述符放到一个 list 里。
while(1) {
connfd = accept(listenfd); 阻塞建立连接
fcntl(connfd, F_SETFL, O_NONBLOCK); 将文件描述符设置为非阻塞
fdlist.add(connfd);
}
while(1) {
// 把一堆文件描述符 list 传给 select 函数
// 有已就绪的文件描述符就返回,nready 表示有多少个就绪的
nready = select(list);
// 用户层依然要遍历,只不过少了很多无效的系统调用
for(fd <-- fdlist) {
if(fd != -1) {
// 只读已就绪的文件描述符
read(fd, buf);
// 总共只有 nready 个已就绪描述符,不用过多遍历
if(--nready == 0)
break;
}
}
}
存在问题:
-
select 调用需要传入 fd 数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(可优化为不复制)
-
select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(内核层可优化为异步事件通知)
-
select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)
2.2 poll模型
它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。
2.3 epoll模型
针对select 模型的三个问题进行了改进。
-
内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
-
内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。
-
内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。
具体,操作系统提供了这三个函数。
//第一步,创建一个 epoll 句柄
int epoll_create(int size);
//第二步,向内核添加、修改或删除要监控的文件描述符。
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
//第三步,类似发起了 select() 调用
int epoll_wait(int epfd, struct epoll_event *events, int max events, int timeout);
文章来源:https://www.toymoban.com/news/detail-427817.html
参考文章https://www.dandelioncloud.cn/article/details/1615702819904651265文章来源地址https://www.toymoban.com/news/detail-427817.html
到了这里,关于网络模型与 IO 多路复用的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!