性能文章>图解Socket核心内幕以及五大IO模型>

图解Socket核心内幕以及五大IO模型原创

1年前
319525

本文为《高性能网络编程游记》的第一篇 图解Socket核心内幕以及五大IO模型。

都说Java天生适合做网络编程,因为其网络模块为我们屏蔽了很多底层网络通信技术细节,简单易用,再配合强大的高性能网络框架如Netty的加持,写高性能网络处理程序那可是信手捏来。

不过,我们要去分析高性能背后的原理,那么就必须对底层的socket套接字有一个基本的认识,并且知道有哪些潜在的底层问题需要解决了。

面试经常问到三次握手,四次挥手,一般的Java开发朋友很少会考虑这些,除了在排查服务器性能问题的时候,但是一旦我们尝试去了解socket套接字的内幕的时候,以及做一些面向套接字的C语言编程的时候,处理三次握手和四次挥手就跟家常便饭一样了。

本文,我们将介绍socket套接字的常用API,网络请求响应处理流程,以及相关的问题和优化手段,最后,我们图解介绍对比五种IO模型,分析其性能优劣。基本上网络编程都是需要靠这些IO模型去实现的。

看完本文,您将了解到:

  • 服务端连接流程,以及相关套接字API底层原理;

  • 客户端连接流程,以及相关套接字API底层原理,三次握手和四次挥手的执行细节;

  • 当服务器队列满了,有新的客户端connect请求的SYN到达时怎么办?

  • IO执行流程中导致IO阻塞的各个环节,发送缓冲区和接收缓冲区是如何影响TCP和UDP阻塞状态的;

  • 子进程关闭了监听套接字,为什么不会导致监听套接字关闭;

  • 五种IO模型,细品应对IO阻塞之道。

1、从套接字联网API的角度看网络通信整体流程

这一节我们首先通过经典的TCP网络通信来讲解整体流程。

下面我们逐个函数介绍,看看网络通信整体流程。

1.1、服务端准备连接流程

1.1.1、socket函数

为了执行网络I/O,我们要做的第一件事情就是调用socket[1]函数,指定期望的协议类型。该函数会创建一个通信的端点,并返回引用该端点的文件描述符,该文件描述符也称为套接字描述符(socket descriptor),简称sockfd

以下是socket函数的定义:

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int socket(int domain, int type, int protocol);  // 返回:若成功则为非负描述符,若出错则为-1

其中:

  • domain:domain参数指定通信域,这将选择将用于通信的协议簇,如AF_INET、AF_INET6、AF_LOCAL等;

  • type:type参数指明套接字类型,可以是:

  • SOCK_STREAM:字节流套接字;

  • SOCK_DGRAM:数据报套接字;

  • SOCK_SEQPACKET:有序分组套接字;

  • SOCK_RAW:原始套接字。

  • protocol:协议类型常值,或者设为0,可以是:

  • IPPROTO_TCP:TCP传输协议;

  • IPPROTO_UDP:UDP传输协议;

  • IPPROTO_SCTP:SCTP传输协议。

常用的组合如下:

操作系统是一个大型软件,有很多历史遗留问题,其中就包括socket的protocol参数。

protocol参数原本是用来指定通信协议的,但现在基本作废了,由前面两个参数即可确定通信协议,所以使用的时候,这个参数一般设置为0。

1.1.2、bind()函数

bind()^2函数把一个本地协议地址赋予一个套接字。

如果一个TCP客户端或者服务器没有调用bind绑定端口,或者指定IP地址,那么内核就会为该套接字选择一个临时端口号。

以下是bind函数定义:

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);  // 返回:若成功则为0,若出错则为-1

大家在启动Tomcat项目的时候,常见的Address already in use错误就是这一步报的,这种错误,在套接字API中,对应为EADDRINUSE错误。

又是由于历史原因,导致了该函数需要addrlen入参。因为最初设计套接字API的时候,C语言没有void *(无类型指针) 的支持,于是就创造了通用地址格式sockaddr来支持bind、accept这些函数,为了判断实际传入的sockaddr类型,于是加入了addrlen参数进行判断。

看操作系统真的是有太多历史原因要考究了。

1.1.3、listen()函数

通过socket()函数创建了套接字之后,再执行listen()函数,会把套接字转换为一个被动套接字指示内核应该接收指向该套接字的连接请求,并导致套接字从CLOSED状态转换到LISTEN状态。

以下是listen()^3函数定义:

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int listen(int sockfd, int backlog);

可以发现,还有一个backlog参数,该参数指定内核应该为相应套接字排队的最大连接个数。

关于已完成连接队列和未完成连接队列

在客户端请求服务器之后,服务器内核会把请求套接字放入到未完成队列中:

如下图:

服务器接收到客户端SYN请求后,于是请求套接字进入未完成连接队列,等到服务端响应了ACK和SYN完成三次握手后,于是,套接字进入已完成连接队列。已完成连接队列中的套接字可以被服务器进程执行accept函数获取到。

1.1.4、accept()函数

服务器一旦执行了socket()、bind()、listen()函数之后,就表示已经初始化好了监听套接字,并且把自己变为了被动套接字,等待客户端的请求。这个时候,我们需要继续调用accept()函数,让服务器进入阻塞等待获取客户端的已连接套接字。

当进程调用accept()时,已完成连接队列中的队头将返回给进程,或者如果队列为空,那么进程将被投入睡眠,直到TCP在该队列中放入一项才唤醒它。

accept()^4函数定义如下:

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

#define _GNU_SOURCE             /* See feature_test_macros(7) */
#include <sys/socket.h>

int accept4(int sockfd, struct sockaddr *addr, socklen_t *addrlen, int flags);

如下图,在调用accept()之后,阻塞等待客户连接到达,然后获取一个已连接套接字:

关于监听套接字和已连接套接字

注意,这里要区分好服务端的监听套接字和已连接套接字,服务端调用socket()返回的是监听套接字,bind()和listen()函数入参也是监听套接字。

一旦有客户端请求过来了于是产生了一个已连接套接字,后续和客户端的交互是通过这个已连接套接字进行的。监听套接字只负责监听客户端请求并获取和客户端的已连接套接字。

1.2、客户端发起连接流程

1.2.1、connect()函数

以下是connect()^5函数定义:

#include <sys/types.h>          /* See NOTES */
#include <sys/socket.h>

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

connect()函数由客户端调用,请求与服务端建立连接,这个函数会触发三次握手。大致流程如下:

  • 客户端:

  • connect调用是的当前套接字从CLOSED状态进入SYN_SENT状态,如果节而受到了服务器的SYN+ACK响应,则转移到ESTABLISHED状态;

  • 如果connect失败,则表示套接字不在可用,必须关闭,不能再次尝试调用connect函数;

  • 服务端:

  • 每当接收到SYN时,TCP在未完成连接队列中创建一个新的条目,然后响应TCP三次握手的第二个分节;

  • 每当收到三次握手的第三个分节的时候,就把条目从未完成队列移到已完成连接队列的队尾。此时,服务端accept调用将被唤醒并得到一个已连接套接字。

注意:客户在调用connect函数之前,不是一定要调用bind函数,这个时候内核会确定源IP地址,并选择一个临时端口号作为源端口号。

所以大家在监控TCP连接的时候,可以发现请求客户端的端口都是没有什么规律的。因为这个端口号是临时端口号。

当服务器队列满了,有新的客户端connect请求的SYN到达时怎么办?

这个时候,TCP会忽略这个SYN分节,也不会向客户端发送RST,好让客户TCP有机会重发SYN,以便在不久之后可以在这些队列中找到可用的空间。

如果直接响应RST,客户的connect()调用会立刻返回错误,导致应用进程必须要处理这种情况。

因为从服务端的角度来看,一个RTT之后,TCP条目就会从未完成队列移动到已完成连接队列。所以,未完成连接队列中的任何一项的存留时间是一个RTT。

一旦connect()成功之后,客户端和服务器就可以通过数据交换相关函数进行数据交换了。

1.3、关闭连接

当需要关闭连接的时候,主动发起关闭的一方先调用close[6]函数,被动关闭一方接收到对方close产生的文件结束符之后,也发起一个close调用,最终双方都关闭了连接,这个过程涉及到连接关闭的四次挥手。

close函数定义如下:

#include <unistd.h>

int close(int fd);

具体关闭流程如下图:

  • 主动发起关闭方首先执行close()函数,协议层发出FIN请求,进入了FIN_WAIT_1状态;

  • 被动关闭方协议层接收到FIN请求后,响应一个ACK,并产生一个文件结束符传递个应用进程,应用进程read()读取到文件结束符,就要开始处理关闭连接了,随后被动关闭方确认没有数据需要再发送给对方之后,也发起一个close()调用,触发协议层向主动关闭方发出FIN请求;

  • 主动关闭方接收到ACK之后,进入了FIN_WAIT_2状态;

  • 主动关闭方接收到FIN之后,进入TIME_WAIT状态,开启2MSL计时器,并发送ACK给被动关闭方;

  • 被动关闭方接收到自己发出的FIN的ACK之后,于是套接字进入CLOSED状态。

1.3.1、并发服务器关闭连接

为了演示并发服务器的流程,这里我们先来介绍一种并发服务器的实现方式,每个已连接套接字开启一个独立进程来处理。

以下是通过独立进程处理每个请求已连接套接字的程序框架:

pid_t pid;
int listenfd, connfd;
// 创建监听套接字
listenfd = Socket(...);
// 绑定一个众所周知的端口号
Bind(listenfd, ...);
// 转变为被动套接字,处于LISTEN状态
Listen(listenfd, LISTENQ);
for (;;) {
  // 尝试获取一个已连接套接字,如果暂时没有则阻塞
  connfd = Accept(listenfd, ...);
  // fork一个子进程处理已连接套接字
  if((pid = Fork()) == 0) {
     // 子进程中关闭监听套接字
      Close(listenfd);
     // 执行具体业务
      doit(connfd);
     // 执行完毕之后关闭已连接套接字
      Close(connfd);
     // 退出子进程
      exit(0);    
  }
  // 父进程中关闭已连接套接字
  Close(connfd);
}

上面的例子中,子进程关闭了监听套接字,为什么不会导致监听套接字关闭呢?父进程关闭了已连接套接字,为什么不会退出已连接套接字呢?这就要讲到套接字的引用计数了。

关于套接字引用计数

每个文件或者套接字都会有一个引用计数,它是当前打开着的引用该文件或者套接字的描述的个数,只有引用计数为0的时候,才真正的清理和释放套接字的资源。

整个fork处理流程如下:

可以发现,fork子进程之后,监听套接字和已连接套接字都复制了一份,子进程关闭监听套接字和父进程关闭已连接套接字都是让这两个套接字引用计数减为了1。

2、基于UDP的套接字联网API

看完了TCP的套接字联网API之后,UDP的就简单多了。我们知道UDP不用通过三次握手建立连接,也就不用维护连接状态,数据的发送也不保证可靠,但是仍然需要通过IP和端口号进行交互。

这里我们大概看一下通信流程:

如上图,这里不用监听套接字等待客户端建立连接,直接通过sendto和recvfrom函数即可进行收发数据。

3、套接字API之殇

我们先来思考以下问题:

3.1、为什么IO操作会阻塞?

我们先来说下发送接收缓冲区。

3.1.1、TCP中的发送缓冲区和接收缓冲区

TCP的发送缓冲区:输出操作首先会将内存中的内容拷贝到内核的TCP发送缓冲区,至于什么时候从网络接口发出去,我们是无法控制的,由TCP协议层决定,具体涉及到TCP的流量控制、窗口管理机制以及拥塞控制,具体可参考之前发的这篇文章:三万长文50+趣图带你领悟web编程的内功心法

简单来说:TCP提供了可靠数据传输功能,为此发送端需要收到发送报文的ack确认消息之后才可以确定消息是发送出去了,发送端将内核的发送缓冲区的数据发送出去后,并不会立刻清除缓冲区。如果接收端一直接收不过来,就会导致发送缓冲区中已发送未确认的记录越来越多,最终导致输出操作阻塞

TCP的接收缓冲区:同样的,接收端接收数据后放入到接收缓冲区中,然后对接收的数据返回一个ack确认信息,如果接收端还没有发送ack信息出去,或者应用进程一直没来取接受缓冲区已接受已确认的数据,那么就不会清除接收缓冲区的数据。

也就是说:接收端接收数据的速度赶不上发送端发送数据的速度的时候,接收端就会发送一个零窗口通告(TCP ZeroWindow),告知发送端不要再发送数据了,我已经处理不过来了,于是发送方就暂停发送数据了,等待接收端的窗口更新(TCP Window Update)通知:

由于收到零窗口通告,导致发送端也会暂停发送数据,最终发送端输出被阻塞。

可以发现:发送缓冲区已发送待确认的数据越来越多,会导致输出操作阻塞;接收缓冲区数据过多,接收端处理不过来,也会导致发送端输出操作被阻塞。

而如果接收缓冲区暂时没有一个字节数据需要应用进程处理的数据,那么输入操作也会被阻塞。

3.1.2、UDP中的阻塞情况

UDP的输入操作:如果UDP套接字没有一个完整的数据报可读,那么就会导致输入操作阻塞;

UDP的输出操作:UDP没有真正的发送缓冲区,内核只是复制应用进程数据并把它沿着协议向下传送,所以对于一个设置为阻塞的UDP套接字,输出函数不会因为与TCP同样的原因导致阻塞,但是可能因为其他原因而阻塞。

3.1.3、accept阻塞

如果对一个阻塞的套接字调用accept函数,并且还没有连接请求到达,那么进程就会进入睡眠状态。对于一个非阻塞的套接字,这种情况,会立刻返回一个EWOULDBLOCK错误。

3.1.4、connect阻塞

如果对一个阻塞的套接字调用connect函数,一直要等到收到发出的SYN的ACK为止才返回,所以每个connect至少会阻塞进程至少一个到服务器的RTT时间。对于非阻塞的套接字,这种情况会返回一个EINPROGRESS错误。

为了避免阻塞导致IO性能下降,所以衍生出了几种IO模型,下面我们来介绍下。

3.2、五种I/O模型

一般而言,一个输入操作,一般会经历如下处理过程:等待数据准备好,从内核复制到进程。这里的数据复制,一般是应用进程调用了某个IO方法之后,陷入系统调用,在内核态完成的。

下面我们通过UDP的recvfrom()函数来说明具体的IO模型。

3.2.1、阻塞式I/O模型

如上图,在应用进程调用 recvfrom()之后,陷入内核态,直到数据报到达并且复制到应用进程缓冲区之后才返回到用户态。

或者在系统调用期间发生错误,也会立刻返回。

这种I/O称为阻塞I/O。

3.2.2、非阻塞式I/O模型

如上图,当我们把套接字设置为非阻塞模式的时候,内核会这样处理I/O操作:当请求的I/O操作非得把本进程投入睡眠才能完成时,就不要投入睡眠,而是返回一个错误,在上面的例子中,调用recvfrom()之后,因为数据没有准备好,所以内核直接返回一个EWOULDBLOCK错误,直到数据准备好了,才复制数据到进程空间,并返回系统调用继续处理进程逻辑。

应用进程会不断循环调用recvfrom()函数,这种处理方式我们称为polling(轮训),持续到轮训内核,查看数据是否准备好。这种方式的缺点是会消耗大量的CPU时间

注意:当recvfrom发起系统调用发现数据准备好了,将数据从内核复制到用户空间的时候,应用进程还是会被阻塞,只不过不会阻塞在等待数据准备好这个流程,从而减少了阻塞时间。

这种轮训对于单进程或者单线程的程序特别有用。

3.2.3、I/O复用模型

I/O复用(I/O multiplexing),指的是通过一个支持同时感知多个描述符的函数系统调用,阻塞在这个系统调用上,等待某一个或者几个描述符准备就绪,就返回可读条件。常见的如select,poll,epoll系统调用可以实现此类功能功能。这种模型不用阻塞在真正的I/O系统调用上。

工作原理如下图所示:

如上图,这种模型与非阻塞式I/O相比,把轮训判断数据是否准备好的处理方式替换为了通过select()系统调用的方式来实现。

select()是实现I/O多路复用的经典系统调用函数。select()可以同时等待多个套接字的变为可读,只要有任意一个套接字可读,那么就会立刻返回,处理已经准备好的套接字了。

在多线中使用阻塞I/O,即每个文件描述符一个线程,与I/O复用模型很类似,每个线程可以自由调用阻塞式I/O系统调用。

3.2.4、信号驱动式I/O模型

所谓信号驱动式I/O(signal-driven I/O),就是指在描述符准备就绪的时候,让内核发送一个SIGIO信号通知应用进程进行后续的数据读取等处理。工作原理如下图所示:

注册了SIGIO信号处理函数,开启了信号驱动式IO之后,就可以继续执行程序了,等到数据报准备好之后,内核会发送一个SIGIO信号给应用进程,然后应用进程在信号处理函数中调用recvfrom读取数据报。

这种模型在内核等待数据报达到期间进程不会被阻塞,可以继续执行。

3.2.5、异步I/O模型

可以发现,上面所有的I/O模型都会在某一个执行点阻塞,并不是真正的异步的。接下来我们就介绍下真正的异步I/O模型(asynchronous I/O)。如下图:

通过异步处理函数如aio_read告知内核启动某个动作,并且让内核在整个操作完成之后再通知应用进程,内核会在把数据复制到用户空间缓冲区之后再进行通知。整个IO过程应用进程都不会被阻塞。

3.3、五种I/O模型对比

下面我们通过一个表格来总结下这五种I/O模型

下面是执行流程的对比图:

image-20201031111428511

可以很清晰的看到各种I/O模型的不同表现。

只有异步I/O模型与POSIX定义的异步I/O相匹配。


今天关于Socket各大函数的核心内幕以及五大IO模型就介绍到这里了,相信大家对TCP连接过程中三次握手以及四次挥手处理细节有了更加深刻的了解。这里提到的IO复用在后面章节中将会大放异彩,为高性能网络程序打下坚实的基础。IO复用涉及到哪些系统调用,他们为何处理效率高?我们将在后续章节详细解读。

为了把相关系列文章收集起来,方便后续查阅,这里我创建了一个Github仓库,把发布的文章按照分类收集起来了,感兴趣的朋友可以Star跟进:

https://github.com/arthinking/java-tech-stack

References

  1. socket(2) - Linux manual page. Retrieved from https://man7.org/linux/man-pages/man2/socket.2.html ↩︎

  2. bind(2) — Linux manual page. Retrieved from https://man7.org/linux/man-pages/man2/bind.2.html ↩︎

  3. listen(2) - Linux manual page. Retrieved from https://man7.org/linux/man-pages/man2/listen.2.html ↩︎

  4. accept(2) - Linux manual page. Retrieved from https://man7.org/linux/man-pages/man2/accept.2.html ↩︎

  5. connect(2) - Linux manual page. Retrieved from https://man7.org/linux/man-pages/man2/connect.2.html ↩︎

  6. close(2) - Linux manual page. Retrieved form https://man7.org/linux/man-pages/man2/close.2.html ↩︎

  • UNIX网络编程 卷1:套接字联网API. 人民邮电出版社

  • 盛延敏. 网络编程实战. 极客时间

点赞收藏
分类:标签:
arthinking
请先登录,查看2条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
5
2