前言
刚接触golang web编程时,我对网络编程产生过很多疑问,socket是啥?服务器是如何处理并发请求的?I/O多路复用又是什么?等等。本文内容涉及网络编程的基础知识,因为服务大多数是部署在linux,所以网络模型的讲解是基于linux的。希望本文能对你有所帮助。
网络编程基础知识
TCP
TCP是一种面向连接的、可靠的传输协议,一般http协议(应用层协议)将TCP作为传输层协议。
IP和端口
IP用于标识网络中设备的地址,端口用于标识设备的某以应用的地址。打个比方:一个小区就是一个网络,每栋楼就是网络中的一个设备,ip标识单元楼,端口标识楼中的具体一个房间。
文件描述符
文件描述符是类Unix操作系统(比如linux)提供的抽象概念,它用于标识文件,在形式上是非负整数。在类Unix操作系统中,有一个原则:一切皆文件。不管什么资源都可以被视为文件,比如:普通文件、套接字、鼠标键盘,而文件描述符(fd)就是用来表示这些资源的。
Socket(套接字)
Socket是一种用于进程间网络通信的编程接口,虽然Socket在不同的语言上和系统中有不同的实现,但是它的基本概念和功能是一致的,Socket一般会提供socket()、bind()、listen()、accept()、connect()、send()、recv()这些通用方法。
c语言中有Socket库,java中有Socket类,python中有Socket模块,golang中有net包提供的api。
网络编程中一般有两种socekt:监听Socket和已连接Socekt。监听socket用于服务端等待客户端连接,一旦有连接,我们就可以通过accetp()创建已连接socekt来与客户端通信。
用户态和内核态
应用程序获取的请求数据都是来源于内核空间(内核态),而应用程序的数据都存在用户空间(用户态),为什么会区分出这两个空间?内核空间和用户空间的划分是为了提供隔离和保护,防止应用程序对系统资源的滥用。
系统调用
在网络编程中常提到的系统调用有recv()、recvfrom()、select()、poll()、epoll()等等。它们都是用来从内核空间获取数据的。顺带一提,recv()从已建立的连接获取数据,recvfrom从未建立的连接获取数据。
网络I/O操作
网络I/O操作指的是在计算机网络中的输入输出,也就是数据的读取和发送,io模型讲解中更多涉及的是数据读取。数据读取分为两个阶段:1.准备阶段等待数据在内核缓冲区准备好(从硬件接口到内核空间) 2.拷贝阶段将数据从内核拷贝到用户进程空间(从内核空间到应用进程空间)。
网络I/O模型
在互联网发展中,人们对单机处理请求能力的要求不断提高,这推动着网络I/O模型不断演化,接下来会依据模型的演变逐一介绍BIO、NIO、IO多路复用。
阻塞I/O模型
作为最初的网络io模型,一个进程或线程只能处理一个请求。
谈起阻塞I/O,最让人想了解的就是:在哪里阻塞?
答案:从应用程序调用recvfrom(),到它返回的这段时间。这段时间就是数据读取的两阶段,具体见下图。
非阻塞I/O模型
阻塞I/O最大的问题就是内核中数据未准备好时,会阻塞进程,这就影响了后续接收新的连接。非阻塞I/O就是基于这一点改进而来。
刚才提到阻塞I/O模型中,调用recvfrom()时的阻塞分两段,那么非阻塞I/O在哪一段阻塞,在哪一段不阻塞呢?
答案:在准备阶段不阻塞,在拷贝阶段阻塞。若调用recv()时数据未准备好,就会直接返回-1,若数据已准备好,则把数据从内核拷贝到用户进程空间,这段拷贝的时间是阻塞的,如下图。
I/O多路复用(目前常用)
虽然非阻塞I/O实现了一个线程管理多个连接,但是它会轮询调用recv(),这其中大部分是无效的,造成很多资源浪费。那该怎么改进?I/O多路复用的思路是:改主动为被动。
先给I/O多路复用下定义:用一个线程监视多个fd,一旦有fd就绪,就通知应用程序进行响应的处理。
那么我们再来从名称上做些思考:复用是复用的什么?
答案:复用的是系统调用。这一观点我与b站up主jaydenwen123是一致的(视频链接在参考标题下)。在非阻塞I/O模型中,每接收一个连接都会调用recv()去获取数据,在I/O多路复用中就变为了通过一次select调用获取所有已就绪的数据,这样就减少了很多无效的系统调用,这也是主动获取和被动通知的区别所在。
前两个模型都提到了阻塞,那I/O多路复用模型在哪阻塞呢?
个人观点:I/O多路复用模型中的阻塞发生在调动select()/poll()/epoll_wait()时,这里的阻塞是针对等待事件的阻塞,而不是针对I/O操作的阻塞。
select
select函数签名
先复习一下Socket和fd的关系:fd用于标识Socket,Socket也可视为文件。
基于select实现多路复用的方式:将已连接socket放入fd集合,然后用select函数(阻塞的)将fd集合从用户态拷贝到内核态,让内核通过遍历fd集合来监视fd状态,发现有fd就绪就把它标为可读或可写,最后把修改后的fd集合拷贝回用户态(阻塞结束),在用户态中遍历fd集合,找到可读或可写socket进行处理。
poll
poll跟select的主要区别就是,select有fd限制(默认1024),而poll无限制。
epoll
select、poll的问题在于找到就绪fd的方式仍是遍历、需要在内核态和用户态之间拷贝fd集合,这两个操作还会发生两次,性能的消耗会随连接数线性增长。
针对fd集合的反复拷贝,epoll的优化是:在内核维护一个红黑树来管理所有需要监视的socket,这个红黑树的节点增加删除也是通过系统调用,应用进程不用管理监视socket,只需要处理就绪fd就行了。
针对获取可读或可写Socket依靠遍历,epoll的优化是:在内核维护一个链表来记录就绪fd,利用事件驱动机制让就绪fd通过回调函数加入此就绪链表,只将就绪fd集合传递给应用进程,减少了无效轮询。
如果不理解已连接socket和epoll之间具体的行为逻辑,可以看看如下的epoll的编程模型
int sock_fd,conn_fd; //监听套接字和已连接套接字的变量
sock_fd = socket() //创建套接字
bind(sock_fd) //绑定套接字
listen(sock_fd) //在套接字上进行监听,将套接字转为监听套接字
epfd = epoll_create(EPOLL_SIZE); //创建epoll实例,
//创建epoll_event结构体数组,保存套接字对应文件描述符和监听事件类型
ep_events = (epoll_event*)malloc(sizeof(epoll_event) * EPOLL_SIZE);
//创建epoll_event变量
struct epoll_event ee
//监听读事件
ee.events = EPOLLIN;
//监听的文件描述符是刚创建的监听套接字
ee.data.fd = sock_fd;
//将监听套接字加入到监听列表中
epoll_ctl(epfd, EPOLL_CTL_ADD, sock_fd, &ee);
while (1) {
//等待返回已经就绪的描述符
n = epoll_wait(epfd, ep_events, EPOLL_SIZE, -1);
//遍历所有就绪的描述符
for (int i = 0; i < n; i++) {
//如果是监听套接字描述符就绪,表明有一个新客户端连接到来,使用epoll_ctl将已连接socket加入监视
if (ep_events[i].data.fd == sock_fd) {
conn_fd = accept(sock_fd); //调用accept()建立连接
ee.events = EPOLLIN;
ee.data.fd = conn_fd;
//添加对新创建的已连接套接字描述符的监听,监听后续在已连接套接字上的读事件
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &ee);
} else { //如果是已连接套接字描述符就绪,则可以读数据
...// read() 读取数据并处理
}
}
}
简述一下上述模型的流程:首先调用epoll_ctl() 把监视Socket加入epoll的监听列表,然后在while循环中通过epoll_wait() 获取就绪fd集合,如果是监视Socket的fd就绪,就表明有新的已连接Socket到来,就调用epoll_ctl() 把这个已连接Socket也加入epoll监听列表;如果是已连接Socket的fd就绪,就读取数据进行处理。
新连接的加入,以及连接的处理就在while循环中完成了。
参考
Unix网络编程卷1:套接字联网API(第3版)
知乎:彻底理解 IO多路复用
ChinaUnix:网络IO模型:同步IO和异步IO,阻塞IO和非阻塞IO
网络编程系列(select、poll、epoll、Reactor模型、Proactor模型)
小林coding:I/O多路复用