乐趣区

关于后端:Linux-网络编程从入门到进阶-学习指南

前言

大家好,我是小康。在上一篇文章中,咱们探讨了 Linux 零碎编程的诸多根底构件,包含文件操作、过程治理和线程同步等,接下来,咱们将视线扩大到网络世界。在这个新篇章里,咱们要让利用跳出单机限度,学会在网络上跨机器交流信息。

接下来,咱们要深刻套接字(sockets)和 TCP/IP 协定,揭示如何在 Linux 下构建通信和网络服务。咱们会从根底说起,逐渐深刻。指标是为初学者提供一个 Linux 网络编程从入门到进阶 的学习指南!

网络通信根底

思考一下,如果计算机想要“交朋友”,它们须要怎么相互沟通?正如人们交换须要应用语言一样,计算机通信也必须恪守一套规定 — 这就是 网络协议

协定确保信息能够在不同的设施和平台之间清晰、精确地传递。要深刻了解协定,咱们首先要相熟两个根底的通信模型:OSI 和 TCP/IP 模型。

OSI 模型和 TCP/IP 模型

在网络通信的世界里,OSI(开放式系统互联通信参考模型)和 TCP/IP(传输控制协议 / 网际协议)模型扮演着根底框架的角色。它们各自形容了网络通信的多个档次和阶段,但以不同的形式来分类和解决数据传输的细节。

  • OSI 模型

    OSI(Open Systems Interconnection)模型是一个概念性框架,用于形容网络中不同操作档次的性能。由七层组成,从物理硬件的电气信号(物理层),到应用层(如网页浏览器),每一层都有其独特的性能和协定。

  • TCP/IP 模型

    TCP/IP 模型,则更加贴近理论网络中的运作。Linux 的网络协议栈就是基于该模型实现的。它是基于四层架构,将网络通信过程简化并集中在协定族上,如传输控制协议(TCP)和互联网协议(IP),这两种协定是古代网络通信中最为外围的局部。

简略图示:

基本概念

地址簿:IP 地址和 MAC 地址

设想一下,互联网是一个微小的数字城市,而每台计算机或网络设备就像是住在这个城市里的居民。

IP 地址:数字世界的“家庭住址”

每台设施的 IP 地址就像是它在这个数字城市里的家庭住址。当计算机须要发送信息或拜访网络资源时,它会应用目的地设施的 IP 地址来确保信息正确地送达。这个地址有点像是咱们事实世界中的邮寄地址,能够依据网络环境的变动而变动(例如,当设施从家庭网络挪动到办公室网络时)。

MAC 地址:网络中的“身份证”

而后,咱们有 MAC 地址,这是网络设备的另一个要害标识。每台设施的 MAC 地址都是举世无双的,相似于每个人的身份证号码。它是在设施制作时就被调配的,并且在大多数状况下,这个地址是固定不变的。MAC 地址在本地网络(如家庭或办公室网络)内起着重要作用,它帮忙确保信息被精确地送达到特定设施,就像邮递员须要晓得收件人的具体身份信息能力将包裹精确递交。

总结一下:ip 地址能够让数据包找到目标主机所在的网络,而 MAC 地址确保数据包能精确送到目标主机上。

导航路线:子网掩码和网关

子网掩码:定位网络的“区域地图”

子网掩码能够被视为定位网络外部和内部地址的“区域地图”。就像在一个大城市中,你须要晓得哪些街道属于你的社区,哪些通往城市的其余局部。子网掩码帮忙计算机确定一个 IP 地址是属于本地网络(即同一个子网)还是位于内部网络。

  • 外部导航:如果目的地 IP 地址与计算机所在的子网相匹配(依据子网掩码判断),则数据包在本地网络内传送。
  • 内部导航:如果目的地不在本地子网内,计算机晓得它须要将数据发送到更远的目的地。

网关:网络间的“中转站”

网关在网络通信中表演中转站的角色。当你的数据包须要从一个网络(比方你的家庭网络)发送到另一个网络(比方你的工作地点的网络)时,网关是这个旅程的第一站。

  • 路由决策:网关查看数据包的目标 IP 地址,而后应用它的路由表来决定最佳的门路将数据包发送到指标网络。

总结: 子网掩码和网关独特合作,帮忙数据包在简单的网络结构中找到最无效的门路。子网掩码确定数据包是否在本地网络内,而网关领导跨网络的数据传输。

端口:确保数据达到正确的“应用程序门牌号”

好了,当初咱们的数据包曾经晓得了去哪里,但它如何确保被正确的程序接管呢?这就是端口退场的时候了。端口号就像是收件人的门牌号,确保数据不只是送到了正确的地址,而且被正确的应用程序接管。

Linux 套接字编程

套接字是什么

在网络编程中,套接字 就像是网络世界的 通信端口。每一个联网的应用程序,为了可能互发音讯,都会应用到这样一个端口。这个端口容许数据从一个程序流向另一个程序。简而言之,套接字是应用程序用来在网络上交换的桥梁。

设想一下,你要用手机给敌人发一条信息。你只须要晓得他们的手机号码,这样信息就能够间接发送到他们的手机上。在网络编程中,套接字的作用相似。它应用 IP 地址(相似于手机号码)来确定数据发送的指标地位,而端口号则像是确定信息应该送达到对方手机中的哪个应用程序。这样,套接字(应用 ip 地址和端口)确保了数据可能精确地发送给正在监听那个特定端口的程序。

套接字的工作原理

套接字的工作原理就像是电话通话的过程。首先,你须要拨打一个号码(即 IP 地址 + 端口号)来建设连贯。一旦连贯建设,电话线(网络连接)就激活了,你的声音(数据)就能够通过它传送。

在这个过程中:

拨号 对应于网络编程中的 连贯建设 ,这是通过调用套接字 API 来实现的,比方 connect() 函数。

通话 对应于 数据传输 ,你能够通过套接字发送 send() 和接管 recv() 数据。

挂断 对应于 完结连贯 ,实现通信后,你须要敞开套接字 close() 函数,以完结会话并清理资源。

在整个通信过程中,套接字保障了数据从一个程序精确地传送到另一个程序,无论这两个程序是在同一台计算机上还是逾越了广大的互联网。

在 Linux 中,套接字其实就是一系列的编程接口,Linux 提供了很多特定的 API 来创立和应用套接字,接下来,让咱们学习如何应用 Linux 套接字 api 来编写各种网络服务程序。

套接字类型

在 Linux 中,有三种套接字类型,前两种是重点把握的,第三种理解即可。

TCP 套接字 (SOCK_STREAM):

  • 这是一种牢靠的套接字连贯,保障数据传输的完整性和程序。
  • 必须先建设连贯,能力传输数据。
  • 罕用于须要精确数据传输的利用,如网页浏览和文件传输。

UDP 套接字 (SOCK_DGRAM):

  • 不须要建设连贯,然而数据传输可能会失落,没有先后顺序。
  • 实用于视频流和在线游戏,这些利用能够容忍肯定的数据失落。

原始套接字 (SOCK_RAW):

  • 容许间接对较低层次的协定如 IP 或 ICMP 进行拜访和操作,它绕过了 TCP 和 UDP 的解决。
  • 开发者能够应用原始套接字来构建自定义的协定或间接解决来自网络的数据包。
  • 通常用于须要进行网络诊断或网络安全利用,如自定义的 ping 实现,或者网络嗅探器。

抉择哪种类型取决于你的利用需要—是否须要牢靠传输(TCP),还是速度更快但可能失落数据也没关系(UDP)。

抉择应用原始套接字通常意味着你须要对网络协议有深刻的了解,因为你将间接与网络层面的数据交互。这比解决 TCP 和 UDP 套接字更简单,通常只在非凡状况下应用,例如网络工具的开发或定制协定的实现。

套接字罕用 API

接下来,看下罕用的套接字 API:

socket()                    : 创立套接字
bind()                      : 绑定套接字到本地地址
listen()                    : 监听网络连接
accept()                    : 承受网络连接
connect()                   : 连贯到近程主机
send(), recv()              : 发送和接收数据(面向连贯的套接字)sendto(), recvfrom()        : 发送和接收数据(无连贯的套接字)close() ,shutdown()         : 敞开套接字
getsockopt(), setsockopt()  : 获取和设置套接字选项

套接字地址构造以及地址转换 API

/*
sockaddr 是一个通用的套接字地址构造,它通常与特定的地址族构造(如 sockaddr_in)一起应用。这是因为少数套接字函数,如 bind(), connect(), 和 accept(),须要应用指向 sockaddr 构造的指针的参数。*/
struct sockaddr {
   sa_family_t     sa_family;      /* Address family */
   char            sa_data[];      /* Socket address */};

// 套接字地址构造(实用于 IPv4 网络通信的地址构造)struct sockaddr_in 
{
    sa_family_t    sin_family; # address family: AF_INET 
    in_port_t      sin_port;   # port in network byte order 
    struct in_addr sin_addr;   # ip address 
};

struct in_addr
{uint32_t       s_addr;    # address in network byte order};

/* 
网络地址转换函数 (用于将 IP 地址在可打印的格局和二进制构造之间转换)
将点分十进制的 IP 地址(如 "192.168.1.1")转换成网络字节程序的二进制模式
inet_pton()   
将网络字节程序的二进制 IP 地址转换为点分十进制字符串格局
inet_ntop()   
*/

# demo 示例:
#define INET_ADDRSTRLEN 16;char str[INET_ADDRSTRLEN];
struct in_addr ipv4addr;
inet_pton(AF_INET, "192.168.1.1", &ipv4addr);
inet_ntop(AF_INET, &ipv4addr, str, INET_ADDRSTRLEN);
printf("The IPv4 address is: %s\n", str);

字节序转换 API

在网络编程中,字节序(也称为端序)指的是数值在内存中保留的程序。不同的计算机体系结构可能会采纳不同的字节序来示意数据。最常见的两种字节序是 大端字节序 (Big-Endian)和 小端字节序 (Little-Endian)。在网络通信中,为了确保数据在不同的零碎间正确传输和解释,定义了一个对立的字节序,即: 网络字节序 ,它采纳 大端字节序

因为主机字节序与网络字节序可能不同,因而在发送数据前,发送方须要将其主机字节序的数值转换为网络字节序;接管方收到数据后,须要将网络字节序的数值转换回主机字节序。

Linux 提供了一组 API 来解决字节序的转换:

// 将无符号长整型数 / 无符号短整型数从主机字节程序转换为网络字节程序。htonl() 和 htons()  
// 将一个无符号长整型数 / 无符号短整型数从网络字节程序转换为主机字节程序。ntohl() 和 ntohs()  

/*
为了不便记忆,大家能够这样了解:h 代表 host(主机),n 代表 network(网络),l 代表 long(四字节:代表 ip),s 代表 short(两字节:代表端口)。以 htons() 举例,host to network short 即:将端口从主机字节序转成网络字节序。*/

留神:htonl 和 ntohl 个别解决的是 IP 地址,而 htons 和 ntohs 个别解决的是端口。

Linux 常见的 IO 模型

后面咱们曾经学习了 Linux 根底的 socket API,这样咱们便能够编写简略的网络服务程序。但当初,咱们面临一个新挑战:如何利用无限的服务器资源,来同时高效解决大量的并发申请呢?

传统的单线程解决形式在古代网络服务中已不合时宜,因为它无奈同时解决多个申请,导致效率低下。为了冲破这一限度,咱们需探索 Linux 提供的各种 I/O 模型。这些模型提供了从阻塞到非阻塞,从多路复用到齐全异步的不同解决方案,以适应各种网络应用场景,确保服务器在面对大量申请时也能放弃高效运行。

在探讨这些 IO 模型之前,咱们先简略回顾一下 I/O 是什么

在计算机中,“I/O”就是输出和输入的简称,它形容了数据在计算机系统和内部世界之间的流动。具体来说:

  • 输出:数据进入计算机,比方你在键盘上敲击字母时,字母被读入计算机。
  • 输入:数据来到计算机,例如屏幕上显示信息。

当提到网络时,“I/O”扩大了含意:

  • 网络输出:从内部网络接收数据到你的本地计算机,如通过网络下载文件到你的计算机。
  • 网络输入:这是指将数据从你的本地计算机发送到内部网络,比方通过计算机发送文件给你的好友。

简而言之,I/O 是数据在计算机和其余设施或网络之间传递的形式。

用户过程如何进行 IO 操作?

让咱们通过一个示意图来直观展现用户过程如何从网络获取数据并将其存储到磁盘的整个过程:

从上图咱们也可能分明的看到,过程进行一次 I/O 操作须要通过两个步骤:

以 read 读操作为例:

第一步:期待网络数据的到来

当网络数据达到时,网络接口卡(NIC)首先通过间接内存拜访(DMA)将数据传输到内核空间调配的 socket 接收缓冲区中,无需 CPU 参加。

第二步:CPU 复制数据至用户空间

一旦数据通过 DMA 传输到内核的 socket 接收缓冲区,用户过程的 read 零碎调用会被唤醒(如果它在期待数据的话)。接下来,CPU 会染指,将数据从内核缓冲区复制到用户空间提供的缓冲区中。

也就是说,在 I/O 操作的过程中,存在两个潜在的等待时间点:一个是期待网络数据达到 socket 接收缓冲区,另一个是期待 CPU 复制数据至用户空间。

为了缩小这些等待时间对应用程序性能的影响,Linux 提供了五种 I/O 模型,它们别离针对这两个步骤的效率问题提供不同的解决方案。

接下来,咱们将深刻理解 Linux 反对的五种 I/O 模型:

阻塞 IO(Blocking I/O)

简略图示

在阻塞 I/O 中,过程在期待网络数据达到和内核复制数据到用户空间这两个步骤中都须要期待。当一个过程发动 I/O 申请时,它会始终期待直到数据被复制到它的应用层缓冲区中,而后才继续执行。

非阻塞 I /O(Non-blocking I/O)

简略图示

在非阻塞 I/O 模型中,当过程尝试从 socket 读取数据时,如果数据尚未达到,read 调用不会阻塞过程。相同,它会立刻返回一个 EWOULDBLOCK 或 EAGAIN 谬误。也就是说,过程不须要期待网络数据达到 socket 接收缓冲区就能够返回继续执行其余工作。

一旦数据达到并存储在内核缓冲区中,而当过程尝试再次读取,这次 read 操作将胜利,并将数据从内核空间复制到用户空间,但这里的数据复制过程是须要期待的。

总结一下 在非阻塞 I/O 模型中,过程须要期待 socket 数据从内核空间复制到用户空间。 而在期待网络数据达到 socket 接收缓冲区这个工夫点是不须要期待的。然而过程须要一直地“轮询”文件描述符,查看 socket 接收缓冲区是否有数据,频繁的轮询可能会导致 CPU 资源的节约。

I/ O 多路复用(I/O Multiplexing)

简略图示

工作原理:

I/O 多路复用容许一个过程或线程同时监控多个网络 sockets 的状态。它通过单个零碎调用(select)来查看多个 sockets 是否有数据可读、可写或是否有异样。Linux 提供了多种 I/O 复用技术,包含下面提到的 select、以及 poll、epoll。

那 I/O 多路复用是如何缩小上述提到的两个潜在的等待时间的?

期待网络数据达到

  • 在 I/O 多路复用模式下,过程不会在单个 socket 上阻塞期待数据达到。相同,当任何一个被监控的 socket 接管到数据,零碎调用(如 select)会返回。当 select 返回时,它批示一个或多个 sockets 已接管到数据。这意味着数据曾经被网络接口卡(NIC)通过 DMA 操作传输到相应的 socket 接收缓冲区中。
  • 这样,过程不用在每个 socket 上别离期待,而是在多个 sockets 上集中期待,进步了效率。

然而,在 I/O 多路复用中,select、poll 或 epoll 零碎调用仍然会阻塞期待网络数据的达到

期待 CPU 复制数据至用户空间

过程随后能够立刻对准备就绪的 socket fd 进行 read 操作。因为数据曾经在内核的缓冲区中,CPU 只须要将数据从内核空间复制到用户空间。但这个拷贝数据的实现

也就是说在 I/O 多路复用中,select、poll 或 epoll 零碎调用仍然会阻塞期待网络数据的达到 ,然而他的 劣势在于能够监控多个 sockets 的接收缓冲区是否有数据到来 。当多个 sockets 的接收缓冲区有数据到来, 过程会始终期待 CPU 复制数据至用户空间能力干其余工作

信号驱动 I /O(Signal-driven I/O)

简略图示

信号驱动 I/O 也是属于 Linux 中的一种 IO 模型,它容许应用程序在不阻塞期待 I/O 操作实现的状况下继续执行其余工作。当 I/O 操作(如数据的读取或写入)准备就绪时,操作系统会向应用程序发送一个信号,告诉它能够开始执行 I/O 操作了。这种模式次要通过应用信号(如 SIGIO)来实现。

两个等待时间点对信号驱动 I/O 的影响 :

期待网络数据达到 :在信号驱动 I/O 模型中,应用程序在期待数据达到时 不须要阻塞期待。它能够继续执行其余工作或处于休眠状态,直到操作系统收回数据已准备就绪的信号(如 SIGIO)。

期待内核复制数据到用户空间 :当应用程序收到信号并开始理论的 I/O 操作(如 read)时, 它依然须要期待操作系统将数据从内核空间复制到用户空间

只管信号驱动 I/O 提供了一种异步告诉机制,使得应用程序可能在 I / O 事件筹备好时接管告诉,但它在实践中不如其余模型(如 IO 复用)那么宽泛应用,起因包含:

  • 编程复杂性:应用信号驱动 I / O 要求程序员相熟信号处理和非阻塞 I / O 操作,这减少了编程的复杂性。
  • 信号合并和失落
    Linux 信号处理机制通常不会为同一类型的信号排队。这意味着如果在解决一个信号时另一个雷同类型的信号产生,后者可能不会触发额定的信号处理调用,导致应用程序可能错过一些 I / O 事件的告诉。这种信号的合并行为限度了信号驱动 I / O 模型在高并发场景下精确响应每个 I / O 事件的能力。
  • 更好的代替计划:对于须要解决多个并发 I / O 操作的应用程序,I/ O 复用(特地是 epoll)提供了更高的效率和更好的管制。epoll 特地实用于高并发场景,并且绝对于信号驱动 I / O 更易于治理和应用。
异步 I /O(Asynchronous I/O)

简略图示

aio_read 是 POSIX 异步 I/O 接口的一部分,它专门用于执行异步文件读取操作。不太实用于网络 I/O。因而下面的图示是基于文件读取对异步 IO 的工作过程进行阐明的。

工作原理:

在异步 I/O 模型中,当应用程序发动一个 I / O 操作(例如 aio_read 读取)时,它不须要期待操作实现就能够继续执行其余工作。应用程序仅仅是向操作系统收回 I/O 申请,并且当 I/O 操作真正实现时,操作系统会告诉应用程序。这种形式容许应用程序更无效地利用 CPU 工夫,因为它不须要在 I/O 操作实现前闲暇期待。

那后面提到的两个潜在等待时间对异步 IO 是否会有影响呢?

期待内核 PageCache 数据筹备好:

在异步 I/O 中,应用程序在收回读写申请后立刻返回,不须要期待数据在内核中筹备好。这意味着应用程序能够继续执行其余工作,而内核会异步的从磁盘读取数据至内核缓存 PageCache 中。

留神: 下面我是通过 aio_read 零碎调用来阐明异步 I/O 的工作原理的,aio_read 是 Linux 的 POSIX 异步 I/O (AIO) 库提供的接口,次要设计用于文件和块设施的异步读写操作,而不反对网络 I/O。因而这里是期待内核的 PageCache 数据筹备好而不是期待网络数据筹备好,但都能够演绎为期待数据筹备好。

期待 CPU 复制数据至用户空间

一旦数据在 pagecache 中筹备好,操作系统负责将这些数据从内核空间复制到用户空间指定的缓冲区。这个复制过程是由内核主动执行的,而不是由用户过程被动复制的。 用户程序不须要期待这一过程的实现,能够持续进行其余工作。只有在数据齐全复制到用户空间后,应用程序才会收到一个实现的信号或告诉。进而解决拷贝至用户空间的数据。

也就是说:在异步 IO 中,不论是期待数据筹备好还是期待 CPU 复制数据至用户空间,用户过程都不须要期待。

Linux 网络 I/O 性能优化

在后面的局部,咱们探讨了 Linux 的 各种 I / O 模型。每种模型都有其独特的应用场景和性能特点。特地是在网络编程中,抉择适合的 I / O 模型对于进步服务器的解决能力至关重要。然而,仅仅抉择适合的 I / O 模型并不足以确保最佳性能。实际上,网络 I / O 性能还受到许多其余因素的影响,比方 网卡配置、带宽、服务器的并发解决能力 等。因而,咱们须要进一步优化 Linux 网络 I/O 性能,以确保咱们的利用能够充分利用服务器资源,提供更快、更牢靠的服务。

那么,如何优化 Linux 网络 I/O 性能呢?

网络 I/O 性能优化次要就是从硬件和软件两个方面来进行:

首先来看下硬件优化:
硬件优化无非就是晋升服务器硬件性能,包含 CPU、网卡配置降级、内存配置降级等。

  • 应用多核 CPU:确保服务器有足够的 CPU 外围来解决高网络负载。
  • 内存降级:减少足够的内存以反对高速网络操作,特地是对于须要大量内存缓存的利用。
  • 网络接口卡 :降级 NIC:应用更高速率的 NIC,例如从 1Gbps 降级到 10Gbps 或更高。
    或者应用 NIC 多队列(Multi-queue):应用反对多队列的 NIC,以便扩散解决负载到多个 CPU 外围。

接下来来看下软件优化:

1. 首先来看下利用程序设计,应用程序自身的设计对网络 I/O 性能有着重大影响:

  • 抉择适合的 I/O 模型
    抉择适合的 I/O 模型,依据利用的特点和需要抉择适合的 I/O 模型。对于高并发的网络服务,I/O 多路复用(如 epoll、kqueue)通常是最佳抉择。它们容许单个线程高效地监控和解决多个网络连接,缩小了线程切换的开销。而对于 I/O 密集型的利用,异步 I/ O 模型可能会更高效,异步 I/O(如 io_uring、libaio)提供了一种不阻塞应用程序主逻辑的形式来解决 I/O 申请。这种模型容许应用程序在 I/O 申请正在解决时继续执行其余工作。
  • 应用零拷贝技术
    传统的数据传输过程波及屡次数据拷贝,包含从内核缓冲区到用户缓冲区。零拷贝技术(如 sendfile)能够缩小这些拷贝操作,间接在内核中解决数据,从而缩小 CPU 应用和提高效率。
  • 批量解决和缓冲 : 汇集数据,以缩小网络交互和磁盘操作的次数。

    a: 汇集数据:通过累积数据达到一定量后再进行解决,而不是每次接管到数据就立刻解决。以读取网络数据下载至本地磁盘为例:能够期待数据积攒到一定量的时候在写入磁盘,这样能够缩小磁盘 I/O 次数。

    b. 缓冲区治理:须要正当治理缓冲区,以防止溢出,并在适当的时候重置或清空缓冲区。

    c. 实用场景:这种模式适宜于数据量大、数据频繁达到的场景,如日志收集、批量数据处理等。

  • 并发和并行处理:利用多核处理器的劣势,通过多线程或多过程来进步并发解决能力。

    2. 接下来看下对于操作系统方面的调整,操作系统级别的调整对于优化网络 I/O 也是至关重要的

  • 减少文件描述符限度:对于高并发的网络服务器,进步文件描述符的限度是必要的,以防止因达到文件描述符下限而无奈承受新连贯。你能够通过 ulimit -n 命令或批改 /etc/security/limits.conf 文件来减少这个限度。
  • 调整 TCP 协定栈参数:常见的 TCP 协定栈参数有如下的几类:

    a:缓冲区大小和资源管理
    这些参数管制 TCP 缓冲区的大小和整体 TCP 缓冲区的资源管理,以优化数据传输性能和内存应用。

    tcp_rmem 和 tcp_wmem:别离管制 TCP 接管和发送缓冲区的大小。

    tcp_mem:管制整体 TCP 缓冲区在零碎范畴内的应用状况。

    b: 连贯建设和终止:
    这类参数波及 TCP 连贯的建设过程和连贯终止时的行为。

    tcp_syn_retries 和 tcp_synack_retries : 别离管制 TCP SYN 连贯申请和 SYN-ACK 包的重试次数。

    tcp_fin_timeout:tcp_fin_timeout 参数设置了 TCP 连贯在 FIN-WAIT-2 状态下的超时工夫。这个参数定义了在一个 TCP 连贯被本地端敞开后,零碎期待对方发送 FIN 包以实现连贯终止过程的最长工夫。如果在这个超时工夫内没有收到对方的 FIN 包,连贯将被强制敞开。

    c : 连贯保活和状态治理:
    这些参数用于检测和维持闲暇连贯,以及治理连贯状态。

    tcp_keepalive_time:设置在开始发送 keepalive 探测之前,一个 TCP 连贯必须处于闲暇状态的工夫。

    tcp_keepalive_probes:设置在断开连接之前,最多发送多少个 keepalive 探测包。

    tcp_keepalive_intvl:设置两个间断 keepalive 探测包之间的工夫距离。

    tcp_tw_reuse : 设置容许在 TIME_WAIT 状态的套接字上的端口被从新用于新的连贯。

    d: 性能优化 : 这些参数用于晋升网络性能,缩小提早。

    tcp_nodelay : 禁用 Nagle 算法,缩小发送小块数据的提早。(Nagle 算法是一种为了缩小网络上小数据包数量而设计的 TCP 个性。它通过累积较小的数据包并将它们组合成更大的数据块来发送,从而缩小了网络上的总数据包数量)。

    tcp_max_syn_backlog : 设置 SYN 接管队列的最大长度,优化高并发连贯的接管。

    除了 SYN 接管队列,TCP 连贯还波及到一个“已连贯队列”(也称为 accept 队列),该队列用于存储曾经实现三次握手、期待应用程序 accept 的连贯。

    /proc/sys/net/core/somaxconn:该参数管制着已连贯队列的最大长度。

    调整办法:
    这些参数通常通过批改 /etc/sysctl.conf 文件或应用 sysctl 命令进行调整。例如:

    sysctl -w net.ipv4.tcp_rmem='4096 87380 6291456'
    sysctl -w net.ipv4.tcp_wmem='4096 16384 4194304'

    留神: 调整这些参数时,应审慎思考零碎的整体资源和利用的具体需要。不失当的设置可能导致性能降落或系统资源耗尽。在生产环境中利用更改前,最好在测试环境中进行充沛的测试。

Linux 常见的服务器模型

服务器模型是网络服务器程序设计的基石,它决定了服务器如何治理多个客户端的连贯和申请。接下来,让咱们来看看 Linux 下的几种常见的服务器模型是怎么工作的?

单过程服务器:一对一服务

在单过程服务器模型中。服务器应用一个主过程来一一解决客户端的连贯申请。这意味着,当服务器正在服务一个客户端时,其余客户端必须期待直到以后客户端服务完结。

图示

单过程回射服务器示例

server_fd = socket();
bind();
listen();
// The main server loop
while (1) {newsockfd = accept(server_fd,...);
    memset(buffer, 0, 256);
    // Read and write to the connection in a loop
    while (1) {n = read(newsockfd, buffer, 255);
        if (n == 0) break; // If read returns 0, the client has closed the connection

        // printf("Client: %s\n", buffer);
        write(newsockfd, buffer, strlen(buffer));
    }
    close(newsockfd);
}

毛病

  • 无奈实现并发:单过程服务器在任何时刻只能解决一个客户端的申请。这意味着如果有多个客户端同时申请服务,除了第一个之外的所有申请都必须期待,这限度了服务器的并发解决能力。
  • 性能瓶颈:因为服务器在解决以后申请时无奈承受新的连贯,这会导致服务器对其余客户端的响应工夫缩短,特地是在高流量的状况下,效率低下。
  • 资源利用不充沛:在多外围处理器上,单过程模型无奈充分利用多核的劣势,因为它只在一个外围上运行,没有并行处理能力。
多过程服务器

理解了单过程服务器模型的毛病后,咱们天然会寻求更高效的计划来解决多客户端并发的状况。这就引出了多过程服务器模型,它是解决单过程模型限度的常见计划。

在多过程模型中,服务器为每个新的客户端连贯创立一个独立的过程。这容许服务器同时解决多个客户端申请,极大地提高了并发解决能力和资源利用率。

图示

多过程回射服务器示例

server_fd = socket();
bind();
listen();

while(1) {int new_socket =accept(server_fd, ...);    
    int pid = fork();
    if(pid < 0) {close(new_socket);
    } else if(pid == 0) {close(server_fd); // Child does not need the listener
      handle_client(new_socket);
    } else {close(new_socket); // Parent does not need this socket
    }
}

void handle_client(int new_socket) {char buffer[1024];
    int bytes_read;
    while(1) {bytes_read = read(new_socket, buffer, sizeof(buffer));
        if (bytes_read <= 0) {break; // Break the loop if read error or end of file}
        write(new_socket, buffer, bytes_read);
    }
    close(new_socket);
    exit(0);
}

多过程服务器长处

  • 稳定性: 多过程服务器中,每个过程是独立的。如果一个过程解体,通常不会影响到其余过程,从而进步了服务器的整体稳定性。
  • 隔离性: 每个过程有本人的地址空间,这意味着过程之间的内存是隔离的。这样能够避免某个过程的错误操作影响到其余过程。
  • 利用多核优势: 多过程模型可能在多核处理器上运行,每个过程能够被操作系统调度到不同的 CPU 外围上,充分利用硬件资源。

毛病

  • 资源耗费: 每个过程都须要一定量的内存和系统资源,如果过程数过多,会占用大量的系统资源,这可能导致服务器的性能降落。
  • 上下文切换开销: 多过程意味着操作系统须要频繁地在过程之间进行上下文切换,这个过程波及到保留和加载寄存器、更新各种表等操作,会耗费肯定的 CPU 工夫。
多线程服务器

尽管多过程模型进步了服务器的稳定性和隔离性,但它也带来了 资源耗费、上下文切换开销 等限度。针对多过程模型的这些限度,多线程服务器模型提供了一个更为高效的解决方案。

多线程服务器模型在同一个过程内创立多个线程来解决客户端申请,每个线程可能独立执行,它们共享过程的资源,如内存空间等资源。而且上下文切换也更快。

图示

多线程回射服务器的示例

server_fd = socket();
bind(server_fd, ...);
listen(server_fd, ...);

while (1) {int new_socket = accept(server_fd, ...);
    pthread_t thread_id;
    if (pthread_create(&thread_id, NULL, handle_client, (void*)&new_socket) != 0) {// Handle error}
}

void* handle_client(void* socket) {int new_socket = *(int*)socket;
    char buffer[1024];
    int bytes_read;
    while (1) {bytes_read = read(new_socket, buffer, sizeof(buffer));
        if (bytes_read <= 0) {break; // Break the loop if read error or end of file}
        write(new_socket, buffer, bytes_read);
    }
    close(new_socket);
    pthread_exit(NULL);
}

多线程服务器优缺点

  • 资源效率: 线程共享过程的内存空间,相较于多过程模型,多线程服务器在内存和资源上的开销更小。
  • 上下文切换效率 : 线程间的上下文切换比过程间的切换要快,因为线程共享许多资源,切换时所需的资源较少( 线程切换个别只须要切换各自寄存器和栈上的数据)。
  • 利用多核优势: 线程能够散布在多个 CPU 外围上运行,这使得多线程服务器可能充分利用多核 CPU 的计算能力。

毛病

  • 同步复杂性: 因为线程共享内存和资源,所以必须认真设计同步机制来防止竞态条件和其余并发问题。
  • 稳定性危险: 一个线程的谬误可能影响整个过程,因为它们共享同一内存空间。这可能导致整个服务器程序解体。
  • 资源限度: 尽管线程比过程轻量,但大量线程依然会耗费大量系统资源,尤其是在高并发状况下。
  • 调试艰难: 多线程程序的调试较为简单,尤其是当呈现了线程平安问题时,这些问题可能难以重现和定位。
线程池模型

在多线程服务器模型中,每个客户端申请都由一个新的线程来解决。这种办法尽管无效,但在面对大量并发申请时,频繁地创立和销毁线程会导致服务器的性能降落。 特地是在申请数量剧增的状况下,线程创立和销毁的开销会变得显著,同时过多的沉闷线程也会竞争无限的 CPU 和内存资源,进一步影响服务的响应工夫和吞吐量。

而在线程池模型中,服务器启动时会事后创立肯定数量的线程,这些线程寄存在池中,并不立刻执行工作。当客户端申请达到时,申请会被调配给线程池中的一个闲暇线程,该线程负责解决整个申请过程。处理完毕后,线程并不销毁,而是返回到池中期待解决下一个申请。

图示

阐明

服务器(Server):这是整个流程开始的中央。服务器初始化一个线程池,并一直监听客户端的连贯申请。当一个客户端连贯申请到来时,服务器承受这个连贯(accept()),而后把相应的工作(job:个别是读写客户端数据的逻辑)增加到线程池的工作队列中去。最终,当服务器不再须要线程池时,会销毁它。

线程池(ThreadPool):线程池是事后创立的线程汇合,用于执行多个工作。它分为两个次要局部:

  • 工作队列(Job Queue):这里寄存所有待处理的工作(jobs)。当服务器承受一个客户端的连贯,它会创立一个工作,并将其增加到这个队列中。
  • 线程队列(Thread Queue):这里寄存的是线程池中所有可用的线程。当工作队列中有工作时,线程池会调配一个线程去执行这个工作。

客户端(Clients):客户端通过网络连接与服务器进行通信。

I/ O 多路复用服务器

什么是 I/O 多路复用?

在 Linux 中,I/O 多路复用是一种容许 单个过程或线程同时监控多个文件描述符(通常是网络套接字)上的读写就绪状态的技术。这使得程序可能在一个或多个文件描述符上产生 I/O 事件时被告诉,从而对这些事件作出响应(比方进行读写操作)。这种机制极大地提高了解决多个并发网络连接的效率,因为它容许应用较少的系统资源(如过程和线程)来治理大量的连贯。

下面咱们在解说 I/O 模型的时候,提到了 IO 多路复用,而在解说服务器模型咱们又再次提到了 IO 多路复用。可能大家会有疑难:IO 多路复用到底属于 I/O 模型还是服务器模型?

其实 I/O 多路复用技术是两者之间的桥梁:它是一种无效的 I/O 解决形式,同时也是构建服务器模型的根底。

  • I/O 多路复用作为 I/O 模型,关注的是如何无效地治理和执行 I/O 操作,特地是在波及多个 I/O 源(如网络套接字)时。
  • I/O 多路复用作为服务器模型,则是在这种 I/O 操作的治理形式根底上构建整个服务器的架构,决定如何接管和解决多个客户端申请,如何调配处理程序来响应这些申请,以及如何利用系统资源。

简略来说,I/O 模型是对于 ” 如何执行 I/O” 的,而服务器模型是基于某种 I/O 模型来构建服务的,以及如何组织服务器程序以响应客户端申请。

常见的 I/O 多路复用技术

Linux 提供了多种 I/O 多路复用的机制,如 select, poll, 和 epoll。这些技术的次要区别在于它们解决大量文件描述符的形式和效率。

IO 复用之 Select

基本概念
Linux 中的 select 函数是一种罕用的 I/O 复用技术。它容许程序监督多个文件描述符(FDs),以检测是否有任何一个或多个 FD 筹备好进行读取、写入或是否有异样产生。这种技术特地实用于同时解决多个网络连接或其余类型的 I/O 操作(如:文件 I /O)。

函数申明

select 函数的根本申明如下:

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds, fd_set *exceptfds, struct timeval *timeout);

/*
参数构造
nfds:监督的文件描述符汇合中最大的文件描述符加一。readfds:一个指向 fd_set 构造的指针,用于监督哪些 FD 筹备好进行读操作。writefds:一个指向 fd_set 构造的指针,用于监督哪些 FD 筹备好进行写操作。exceptfds:一个指向 fd_set 构造的指针,用于监督哪些 FD 有异样产生。timeout:指定 select 期待准备就绪的 FD 的最长工夫。*/

fd_set 构造图解展现

fd_set 是一个文件描述符数组,用于批示 select 函数应该监督的 FDs。

fd_set 构造图解展现

阐明

参数 readfds、writefds、exceptfds 从用户空间传入内核空间和从内核空间返回用户空间,文件描述符数组中的值代表的含意不同:

以可读事件 readfds 为例

从用户空间传入内核空间:数组值为 0 代表不监控该文件描述符(fd),数组值为 1 代表要监控该文件描述符(fd)。

从内核空间返回用户空间:数组值为 0 代表该文件描述符数据未准备就绪,数组值为 1 代表该文件描述符数据准备就绪。用户过程能够进行读操作了。

select 并发回射服务器程序示例


#define PORT 8080
#define MAX_CLIENTS 30

int main() {int server_fd, new_socket, client_socket[MAX_CLIENTS];
    struct sockaddr_in address;
    int addrlen = sizeof(address);
    fd_set readfds;
    int max_sd, sd, activity, i, valread;
    char buffer[1025];  // 数据缓冲区

    // 初始化所有客户端套接字
    for (i = 0; i < MAX_CLIENTS; i++) {client_socket[i] = 0;
    }
    // 创立套接字
    server_fd = socket(AF_INET, SOCK_STREAM, 0)
    // 绑定套接字
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);
    bind(server_fd, (struct sockaddr *)&address, sizeof(address))<0

    // 监听套接字
    listen(server_fd, 3)

    while (1) {FD_ZERO(&readfds);// 清空 fd_set
        FD_SET(server_fd, &readfds); // 增加 server_fd 到 fd_set
        max_sd = server_fd;
        // 增加客户端套接字到 fd_set
        for (i = 0; i < MAX_CLIENTS; i++) {sd = client_socket[i];
            if (sd > 0) {FD_SET(sd, &readfds);
            }
            if (sd > max_sd) {max_sd = sd;}
        }

        // 应用 select 监听套接字
        activity = select(max_sd + 1, &readfds, NULL, NULL, NULL);

        // 承受新连贯
        if (FD_ISSET(server_fd, &readfds)) {if ((new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen))<0) {// 错误处理并退出}
            // 将新套接字增加到数组
            for (i = 0; i < MAX_CLIENTS; i++) {if (client_socket[i] == 0) {client_socket[i] = new_socket;
                    break;
                }
            }
        }
     // 其余套接字的数据处理
    for (i = 0; i < MAX_CLIENTS; i++) {sd = client_socket[i];
        if (FD_ISSET(sd, &readfds)) {
            // 查看是否是断开连接,否则接收数据
            if ((valread = read(sd, buffer, 1024)) == 0) {close(sd);
                client_socket[i] = 0;
            } else {buffer[valread] = '\0';
                // 将接管到的数据发送回客户端
                send(sd, buffer, strlen(buffer), 0);
            }
        }
    }
    return 0;
}

select 优缺点

长处:

  • 可能同时监督多个套接字: select 容许服务器以单线程的形式监督多个套接字,来检测它们是否有可读、可写或异样条件产生。
  • 无需多线程或多过程:select 采纳单线程的解决形式,应用 select 能够防止简单的多线程或多过程治理,缩小了上下文切换的开销,简化了并发解决。
  • 实用于小到中等规模的负载:对于不是很高的并发连接数(几百的连接数),select 通常能够满足需要,且效率不错。

毛病:

  • 文件描述符限度:select 能够监督的文件描述符数量是无限的,通常由 FD_SETSIZE 常量决定,这在很多零碎上默认是 1024。这限度了服务器能够解决的最大并发连接数。当然 select 也会受限于零碎级别的文件描述符数量限度。
  • 效率问题:随着文件描述符数量的减少,select 的性能会线性降落。每次调用 select 时,都须要从新传入整个文件描述符汇合,内核须要遍历这个汇合来更新状态,这在文件描述符很多时会成为瓶颈。
  • 响应工夫变长:在 select 返回的文件描述符列表汇合中,如果有多个文件描述符同时变为沉闷状态,服务器通常会按程序解决它们。这可能导致对列表后面的连贯有偏见,使得前面的连贯等待时间较长。

IO 复用之 Poll

基本概念
poll 也是一种 IO 复用技术,用于监督多个文件描述符(通常是网络套接字)的可读性、可写性和异样状态。与 select 相似,poll 容许您的程序监督多个文件描述符,直到一个或多个文件描述符筹备好进行 IO 操作。这使得您能够同时治理多个网络连接,而不是一一阻塞地解决它们。

函数申明

#include <poll.h>

int poll(struct pollfd *fds, nfds_t nfds, int timeout);

参数阐明:/*
fds:指向一个 pollfd 构造数组的指针,该数组蕴含要监督的文件描述符及其申请的事件(如 POLLIN 示意可读,POLLOUT 示意可写)。nfds:指定数组 fds 中的元素数量。timeout:指定等待时间(毫秒)。非凡值有:0 立刻返回(非阻塞),-1 有限期待直到某个事件产生。*/

pollfd 构造

poll 函数应用 pollfd 构造来指定要监督的文件描述符和事件类型:

struct pollfd {
    int fd;         // 文件描述符
    short events;   // 申请的事件
    short revents;  // 理论产生的事件
};

fd:文件描述符
events:要监督的事件,如 POLLIN、POLLOUT
revents:由 poll 函数设置,表明哪些事件理论产生了

Poll 底层采纳的数据结构图解

底层数据结构:

用户空间数组:用户空间程序应用数组(类型为 struct pollfd)来提供要监督的文件描述符及其感兴趣的事件

但在内核中,为了无效地解决这些文件描述符,poll 的实现转而应用链表。

内核空间链表

  • 当 poll 零碎调用被执行时,内核首先将这个数组中的数据复制到内核空间。
  • 在内核中,为了更灵便地解决可能的大量文件描述符,这些 pollfd 构造被组织成链表模式。
  • 链表的每个节点可能蕴含一个或多个 pollfd 构造,具体取决于可用的内存和文件描述符的数量。

poll 优缺点

长处

无内置文件描述符限度 :与 select 不同,poll 不受文件描述符数量的限度。select 通常受限于 FD_SETSIZE,这在解决大量并发连贯时可能成为瓶颈。 但它依然受限于零碎级别的文件描述符限度。

简化的接口:poll 应用单个构造体数组来示意所有监督的文件描述符和相干事件,相比 select 须要应用三个文件描述符集(读、写、异样),接口更为简洁。

更直观的事件模型:poll 应用位字段来示意不同的事件类型,这使得事件模型比 select 的形式更直观和易于了解。

毛病

线性扫描开销:poll 在解决文件描述符时,须要对整个数组进行线性扫描。当监督的文件描述符数量十分大时,这可能导致性能降落。

总的来说,poll 是 select 的一种改良,特地是在可解决的文件描述符数量上没有限度,但在高性能和大规模并发解决方面,epoll 在古代 Linux 零碎上通常是更佳的抉择。

poll 并发回射服务器程序示例

#define PORT 8080
#define MAX_CLIENTS 30
#define BUFFER_SIZE 1024

int main() {
    int listen_fd, new_socket, valread;
    struct sockaddr_in address;
    int opt = 1;
    int addrlen = sizeof(address);
    char buffer[BUFFER_SIZE];
    struct pollfd client_fds[MAX_CLIENTS];

    // 创立监听套接字
   listen_fd = socket(AF_INET, SOCK_STREAM, 0);// 绑定套接字
    address.sin_family = AF_INET;
    address.sin_addr.s_addr = INADDR_ANY;
    address.sin_port = htons(PORT);

    bind(listen_fd, (struct sockaddr *)&address, sizeof(address)
    listen(listen_fd, 3); // 开始监听
    // 初始化 pollfd 构造
    for (int i = 0; i < MAX_CLIENTS; i++) {client_fds[i].fd = -1;
    }
    client_fds[0].fd = listen_fd;
    client_fds[0].events = POLLIN;

    // 主循环
    while (1) {int activity = poll(client_fds, MAX_CLIENTS, -1);
        if (activity < 0) {// 出错解决并退出}
        // 查看是否有新的连贯
        if (client_fds[0].revents & POLLIN) {if ((new_socket = accept(listen_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {// 出错解决并退出}

            // 增加新的套接字到数组
            for (int i = 1; i < MAX_CLIENTS; i++) {if (client_fds[i].fd == -1) {client_fds[i].fd = new_socket;
                    client_fds[i].events = POLLIN;
                    break;
                }
            }

        }

        // 查看客户端流动
        for (int i = 1; i < MAX_CLIENTS; i++) {if (client_fds[i].fd > 0 && (client_fds[i].revents & POLLIN)) {if ((valread = read(client_fds[i].fd, buffer, BUFFER_SIZE)) > 0) {buffer[valread] = '\0';
                    send(client_fds[i].fd, buffer, valread, 0);
                } else {close(client_fds[i].fd);
                    client_fds[i].fd = -1; // 标记为可用
                }
            }
        }
    }

    return 0;
}

IO 复用之 Epoll

基本概念
epoll 是 Linux 零碎中一种高效的 I/O 事件告诉机制,特地实用于解决大量并发网络连接。与传统的 select 或 poll 办法相比,epoll 的独特之处在于其对沉闷连贯的高效解决能力。它通过保护一个沉闷事件汇合,防止了对所有文件描述符的遍历,显著晋升了性能。这使得 epoll 成为构建高性能网络应用程序的现实抉择。

函数申明

epoll 次要波及三个零碎调用:epoll_create、epoll_ctl 和 epoll_wait。

// epoll_create:创立一个 epoll 实例。int epoll_create(int size);
// poll_ctl:治理(增加、批改或删除)监督的文件描述符。int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
/*
epfd:由 epoll_create 返回的 epoll 实例文件描述符。op:要执行的操作,如 EPOLL_CTL_ADD(增加)、EPOLL_CTL_MOD(批改)、EPOLL_CTL_DEL(删除)。fd:关联的文件描述符。event:指向 epoll_event 构造的指针,指定感兴趣的事件和任何关联的用户数据。*/

// 待在 epoll 文件描述符上注册的事件产生
int epoll_wait(int epfd, struct epoll_event *events, int maxevents, int timeout);
/*
epfd:epoll 实例的文件描述符。events:用于从内核获取事件的 epoll_event 构造数组。maxevents:批示数组中能够返回的最大事件数。timeout:期待事件的最大工夫(毫秒),-1 示意有限期待。*/

epoll_event 构造

struct epoll_event {
    uint32_t     events;    /* Epoll events */
    epoll_data_t data;      /* User data variable */
};

/*
events 字段用于指定感兴趣的事件类型,例如 EPOLLIN(可读)、EPOLLOUT(可写)等。data 字段通常用于存储用户定义的数据,如文件描述符、指向对象的指针等。*/

//epoll_data_t 是一个联结(union),它用于存储用户定义的数据,能够是文件描述符、指针或任何其余用户须要的数据类型。typedef union epoll_data {
    void *ptr;
    int fd;
    uint32_t u32;
    uint64_t u64;
} epoll_data_t;

/*
例如,您能够在 epoll_ctl 调用时,应用 epoll_data_t 的 fd 字段来存储正在监督的文件描述符,或者应用 ptr 字段来存储指向某个对象或构造的指针。这样,在事件产生时,您能够快速访问与该事件相关联的数据。*/

Epoll 的两种触发模式

在 Linux 中,epoll 提供了两种触发模式:程度触发(Level-Triggered, LT)和边缘触发(Edge-Triggered, ET)。了解这两种模式对于应用 epoll 来说十分要害,因为它们决定了在文件描述符(FD)上产生事件时,epoll 如何告诉应用程序。

程度触发(Level-Triggered, LT)

触发条件:只有文件描述符关联的 socket 缓冲区上有数据可读或有空间可写,epoll_wait 就会返回该文件描述符。这意味着,只有有未解决的数据(如缓冲区中还有数据未读),epoll_wait 会一直地告诉应用程序去读数据至用户空间缓存,从而进行解决。

解决形式:这种模式更加容易解决,因为即便应用程序没有一次性解决所有的可用数据,epoll_wait 会再次告诉你该文件描述符上仍有待解决的数据。

程度触发的长处

  • 简略易懂:程度触发的行为更直观,容易了解和实现,尤其是对于那些不太熟悉非阻塞 I/O 编程的开发者。
  • 容错性高:在程度触发模式下,只有文件描述符的状态仍满足条件(如有数据可读),epoll_wait 会继续告诉应用程序,缩小了因脱漏事件处理导致的谬误。

程度触发的毛病

  • 可能的性能开销:在高负载或大量并发连贯的状况下,程度触发可能导致频繁的 epoll_wait 响应。因为只有文件描述符依然处于沉闷状态(例如,仍有数据可读),它就会一直地触发事件。
  • 资源应用效率:因为频繁的事件触发,程度触发模式可能导致更高的 CPU 使用率,尤其是当有大量沉闷的文件描述符时。

程度触发示例代码:

struct epoll_event event;
int epoll_fd, fd;

event.events = EPOLLIN; // LT 是默认模式
event.data.fd = fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event);

// 事件循环
while (true) {struct epoll_event events[MAX_EVENTS];
    int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
    for (int i = 0; i < n; i++) {if (events[i].data.fd == fd) {// 能够读取局部数据,即便不全副读取完,该 FD 依然会再次报告}
    }
}

边缘触发(Edge-Triggered, ET)

触发条件:只有文件描述符的状态产生扭转时,epoll_wait 才会告诉利用过程来读写数据(只告诉一次),直到文件描述符的状态再次发生变化。

那什么才是文件描述符的状态产生扭转呢?

文件描述符的状态产生扭转指的是 fd 从不可读的状态扭转成可读的状态或者 fd 从不可写的状态扭转成可写的状态。

对于 socket 可读事件来说

fd 从不可读的状态扭转成可读的状态,简略了解就是:fd 对应的 socket 接收缓冲区从无数据到有数据。

具体点:就是当数据首次达到一个空的 socket 接收缓冲区时,epoll_wait 会告诉应用程序一次。此时,缓冲区状态从“无数据可读”变为“有数据可读”。又或者应用程序开始读取数据,并将缓冲区中的数据读完(读操作返回 EAGAIN),而后又有新数据达到。这两种状况都是属于 socket 接收缓冲区从无数据到有数据的例子。

再来看个非凡状况:

在 ET 模式下,一旦应用程序开始读取数据,如果没有一次性将缓冲区中的所有数据都读取完(即仍有未读取的数据留在缓冲区中),此时不会触发新的 epoll_wait 告诉。然而如果此时接收缓冲区又来了新数据,即便文件描述符的状态并没有产生扭转,但也会触发新的 epoll_wait 告诉的。

对于 socket 可写事件来说

文件描述符的状态产生扭转指的是:fd 从不可写的状态扭转成可写的状态。简略了解就是:fd 对应的 socket 发送缓冲区从满到不满。 对于可写事件,存在以下两个场景:

场景一:继续有空间

假如你有一个 socket 连贯,你正在向它发送数据。在边缘触发(ET)模式下:

  • 初始状态:连贯建设后,发送缓冲区为空,所以你能够开始发送数据。
  • 继续发送:只有发送缓冲区有空间,你能够持续发送数据。在这个过程中,如果缓冲区从未真正变满过(即,你的发送速度不超过网络层解决和发送数据的速度),epoll_wait 不会因为缓冲区有空间而特地告诉你,因为从 epoll 的角度看,这不是一个“状态变动”。
场景二:缓冲区满后又有空间

当初,让咱们看一个缓冲区理论变满的状况:

1. 发送直至满:你持续发送数据,直到达到一个点,发送缓冲区满了,这时,尝试再发送数据会失败(通常返回 EAGAIN 或 EWOULDBLOCK)。

2. 期待可写:这时,你应该进行发送数据,期待 epoll_wait 告诉你 socket 再次可写。

3. 缓冲区局部清空:随着工夫的推移,网络层将缓冲区中的数据发送进来,缓冲区从“满”变为“有空间”(即,局部数据被发送进来,为新数据腾出了空间)。

4. 收到告诉:因为缓冲区的状态从“满”变为“有空间”,这是一个状态变动,epoll_wait 会告诉你 socket 当初可写。

解决形式:ET 模式要求你必须一次性解决所有可用的数据。如果解决不齐全,epoll_wait 不会再次告诉你该文件描述符上的事件,除非新的数据达到或再次变为可写。

边缘触发示例代码:

struct epoll_event event;
int epoll_fd, fd;

event.events = EPOLLIN | EPOLLET; // 启用 ET 模式
event.data.fd = fd;
epoll_ctl(epoll_fd, EPOLL_CTL_ADD, fd, &event);

// 事件循环
while (true) {struct epoll_event events[MAX_EVENTS];
    int n = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);
    for (int i = 0; i < n; i++) {if (events[i].data.fd == fd) {while (true) {ssize_t count = read(fd, buf, sizeof(buf));
                if (count == -1) {if (errno != EAGAIN) {// 解决非 EAGAIN 谬误}
                    break; // 没有更多数据可读
                }
                // 解决读取的数据
            }
        }
    }
}

边缘触发的优缺点:

长处:

缩小告诉频率:

在 ET 模式中,epoll_wait 只在文件描述符(如套接字)的状态发生变化时告诉一次(例如,从不可读变为可读)。这缩小了零碎一直查看状态的须要,尤其在治理大量连贯时十分无效。

进步事件处理效率:

因为缩小了频繁的事件告诉,应用程序能够更集中地解决每次告诉的事件。这在同时解决许多连贯时晋升了效率。每次事件都失去了充沛的解决,而不是浪费资源在反复或不必要的查看上,从而整体进步了解决大量并发连贯的效率。

升高资源占用:

ET 模式通过缩小频繁的事件检查和解决,有助于缩小 CPU 和内存的应用,尤其在高负载下。

更好的扩展性:

对于须要解决大量并发连贯的高性能服务器,ET 模式可能更高效地利用资源,使服务器可能承载更多的连贯,从而晋升整体的扩大能力。

毛病:

解决逻辑更加简单:

在 ET 模式下,必须在每次告诉时尽可能残缺地解决 I/O 事件(读取或写入所有数据),因为雷同条件下不会再次收到告诉。这要求程序可能无效地一次性解决大量数据。

可能会错过一些数据:

如果在解决告诉时没有齐全读取或写入所有数据,残余的数据可能不会触发新的告诉,导致程序错过一些重要数据。

依赖于非阻塞 I/O:

ET 模式通常和非阻塞 I/O 联合应用。在这种模式下,编程变得更简单,因为须要解决非阻塞调用可能遇到的非凡状况,如 EAGAIN 或 EWOULDBLOCK。

总结:

程度触发:更易于应用和了解,但可能会导致更多的 epoll_wait 调用,尤其是在高负载下。

边缘触发:更高的性能后劲,缩小了 epoll_wait 调用的次数,但须要更审慎的缓冲区治理和错误处理。

Epoll 优缺点

长处:

1. 高效的文件描述符治理

在 epoll 中,高效的文件描述符治理首先依赖于高效的数据结构(红黑树和链表)以及回调函数。epoll 应用红黑树来组织所有监控的文件描述符,提供疾速的查找、插入和删除操作。链表则用于存储准备就绪的事件,使得 epoll_wait 能迅速返回这些事件。每当监控的文件描述符产生状态变动(例如,socket 上有数据到来)时,与之关联的回调函数被内核主动触发。这些回调函数间接将就绪的文件描述符事件增加到 epoll 的就绪链表中。使得 epoll_wait 疾速返回,这种集成了回调机制和高效数据结构的办法,使 epoll 在解决大量并发连贯时比传统的 select 和 poll 办法更高效。

相比之下,select 和 poll 每次调用时都须要遍历整个文件描述符汇合,以查看每个描述符的状态。当文件描述符数量很大时,这种办法的效率显著升高。

2. 更好的可扩展性

epoll 可能解决的文件描述符数量远超过 select 的 FD_SETSIZE 限度(通常为 1024),使其可能更无效地解决成千上万的并发连贯。这使 epoll 成为高并发网络应用的现实抉择,例如大型网站的服务器。

3. 缩小复制操作

在传统的 select 和 poll 办法中,应用程序须要在每次调用时将整个文件描述符汇合从用户空间复制到内核空间,内核解决完后再将后果复制回用户空间。这种来回复制操作效率比拟低。

而在 epoll 中,只须要将就绪事件从内核空间的就绪链表复制到用户空间,而非整个被监控的文件描述符汇合。这种机制大大减少了数据在用户空间和内核空间之间频繁来回复制的需要,特地是在只有多数文件描述符就绪的大规模并发连贯场景中,显著升高了上下文切换和数据复制的开销,从而进步了整体的效率和性能。

4. 反对边缘触发(ET)和程度触发(LT)

  • 两种触发模式:epoll 提供了边缘触发(ET)和程度触发(LT)两种模式。边缘触发仅在文件描述符状态发生变化时告诉一次,而程度触发则在描述符放弃某状态时继续告诉。
  • 适应不同的应用场景
    这种灵活性使得开发者能够依据具体的利用需要和行为特点抉择最合适的模式,以优化性能。

毛病:

平台依赖性: epoll 是 Linux 特有的,不具备 select 和 poll 那样的跨平台个性。这意味着基于 epoll 的应用程序不能在非 Linux 零碎上间接运行,限度了其可移植性。

边缘触发模式的挑战

在边缘触发(ET)模式下,epoll 只在状态变动时告诉一次。这意味着应用程序必须正确处理所有的数据,否则可能会失落未解决的事件。

Epoll 并发回射服务器程序示例

#define MAX_EVENTS 10
#define READ_BUF_SIZE 1024
#define PORT 8080
int main() {
    int listen_fd, conn_fd, epoll_fd;
    struct sockaddr_in server_addr;
    struct epoll_event event, events[MAX_EVENTS];
    int event_count, i;
    char read_buf[READ_BUF_SIZE];

    socket(AF_INET, SOCK_STREAM, 0);
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);
    bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr))
    listen(listen_fd, SOMAXCONN)

    epoll_fd = epoll_create1(0);
    event.events = EPOLLIN;
    event.data.fd = listen_fd;

    epoll_ctl(epoll_fd, EPOLL_CTL_ADD, listen_fd, &event)

    while (1) {event_count = epoll_wait(epoll_fd, events, MAX_EVENTS, -1);

        for (i = 0; i < event_count; i++) {if (events[i].data.fd == listen_fd) {conn_fd = accept(listen_fd, NULL, NULL);
                if (conn_fd == -1) {perror("accept");
                    continue;
                }
                event.events = EPOLLIN | EPOLLET;
                event.data.fd = conn_fd;
                if (epoll_ctl(epoll_fd, EPOLL_CTL_ADD, conn_fd, &event) == -1) {perror("epoll_ctl");
                    close(conn_fd);
                    continue;
                }
            } else {int nbytes = read(events[i].data.fd, read_buf, READ_BUF_SIZE);
                if (nbytes <= 0) {close(events[i].data.fd);
                } else {write(events[i].data.fd, read_buf, nbytes);
                }
            }
        }
    }
    close(listen_fd);
    close(epoll_fd);
    return 0;
}
异步 I / O 服务器模型

在讲述了 I/O 多路复用服务器模型后,咱们看到它如何使单个过程可能无效地治理多个网络连接。通过 select、poll 或 epoll,过程能够在多个连贯上同时期待数据,而无需为每个连贯阻塞期待。这种办法晋升了并发解决的效率,但它有一个局限性:一旦某个连贯的 I/O 操作开始,该过程必须期待该操作实现能力持续解决下一个连贯。 简略了解就是:解决各个连贯的 IO 读写是同步的,是串行的。

为了解决这一问题,引入了异步 I / O 服务器模型。这种模型极大晋升了服务器的工作解决能力,它容许过程在发动 I / O 操作后立刻转而执行其余工作,而无需期待 I / O 操作的实现。这一过程由操作系统在后盾治理,一旦 I / O 操作实现,过程便会收到告诉。过程只须要去解决已被拷贝至应用层缓冲区的数据。

Linux 中的异步 I / O 实现

在 Linux 中,异步 IO 模型次要由 Linux aio(通过 libaio 库)和 io_uring 两种技术来实现。

Linux aio(libaio)

Linux aio 是 Linux 零碎中较早反对的异步 I / O 机制。它通过 libaio 库提供了一系列的 API,容许应用程序非阻塞地启动和治理 I / O 操作。当一个 I / O 申请被提交后,libaio 负责将其发送到操作系统,应用程序能够继续执行而无需期待。一旦 I / O 操作实现,应用程序将通过回调函数或其余机制失去告诉。

libaio 提供的 API :

io_setup:创立一个异步 I / O 上下文。io_submit:向异步 I / O 上下文提交一个或多个 I / O 申请。io_getevents:从异步 I / O 上下文中获取已实现的事件。io_destroy:销毁一个异步 I / O 上下文。

只管 libaio 为异步 I/O 提供了根底反对,但它有肯定的局限性,比方:它只实用于文件 I/O,并不适宜用于网络 I/O。

以下是一个简洁的 libaio 应用示例,演示了如何在 Linux 零碎中以异步形式进行文件读取。

#include <libaio.h>
#include <sys/eventfd.h>

#define FILE_PATH "example_file.txt"
#define BUFFER_SIZE 1024
int main() {
    io_context_t ctx;
    struct iocb cb;
    struct iocb *cbs[1];
    char buffer[BUFFER_SIZE];
    int file_fd, efd, ret;
    struct io_event events[1];
    uint64_t u;
    memset(&ctx, 0, sizeof(ctx));
    io_setup(10, &ctx);
    file_fd = open(FILE_PATH, O_RDWR | O_CREAT, 0644);
    // 创立 eventfd 用于告诉
    efd = eventfd(0, 0);
    // 筹备异步读申请
    memset(&cb, 0, sizeof(struct iocb));
    io_prep_pread(&cb, file_fd, buffer, BUFFER_SIZE, 0);
    // 设置 eventfd 作为实现事件的告诉
    cb.data = (void *)(uintptr_t)efd;
    cbs[0] = &cb;
    // 提交异步 I / O 申请
    io_submit(ctx, 1, cbs)
    // 在这里,主线程能够执行其余业务逻辑
    // ...
    read(efd, &u, sizeof(uint64_t));  // 在主线程中期待告诉
    io_getevents(ctx, 1, 1, events, NULL);  // 读取异步 I / O 事件
    // 解决实现的 I / O 事件
    if(events[0].data == (void *)(uintptr_t)efd) {printf("Read %zd bytes: %.*s\n", events[0].res, (int)events[0].res, buffer);
    }
    close(file_fd);
    io_destroy(ctx);
    close(efd);
    return 0;
}

io_uring

io_uring 是 Linux 内核 5.1 版本引入的全新异步 I/O 框架,io_uring 旨在提供一种高效、灵便且功能丰富的形式来执行异步 I/O 操作。与 libaio 相比,io_uring 提供了更低的零碎调用开销,更简略的接口,以及更好的性能。

如何工作

io_uring 的核心思想是通过两个队列来治理异步 I/O 申请。一个叫做提交队列(SQ),另一个叫实现队列(CQ)。

1.提交申请:当你的程序想要执行一个 I/O 操作,比方读取网络数据,它会创立一个申请并把它放到提交队列(SQ)中。

2.内核解决:Linux 内核会查看提交队列,取出申请并解决它们。你的程序不须要期待内核实现这个操作,它能够持续做其余事件。

3.实现告诉:一旦内核实现了一个申请,它会把后果放入实现队列(CQ)中。这样程序就晓得该操作曾经实现,能够持续处理结果了。

io_uring 的劣势:

  • 性能:它容许应用程序一次性地批量提交多个 I/O 申请,缩小了零碎调用的数量,所以 io_uring 可能提供比传统的异步 I/O 更好的性能。
  • 缩小期待:应用程序不须要每次提交一个申请就期待后果,它能够继续执行其余工作,同时内核在背地解决这些 I/O 申请。
  • 功能丰富:io_uring 反对各种类型的 I/O 操作,包含但不限于文件读写、网络操作等。
  • 易用性:io_uring 提供了一个更为简洁和统一的接口,相比于旧的异步 I/O 接口,它更易于应用和了解。

对于异步 IO 服务器模型的学习,大家只须要了解异步 IO 的工作形式,以及理解在 Linux 中能够通过 libaio 和 io_uring 技术能够构建异步 IO 服务器模型。如果想深刻学习 io_uring 的底层原理,则能够去官网或者谷歌搜寻相干材料去深刻学习。

这篇文章,大家能够去理解:
https://cloud.tencent.com/developer/article/2187655

对于具体的代码示例,则能够去理解 liburing 这个库的 example 代码示例:https://github.com/axboe/liburing

服务器架构模式

在后面的介绍中,咱们理解了常见的服务器模型,然而这些模型在应答高并发场景都会遇到一些挑战,特地是在解决大量并发连贯和高效率 I/O 操作方面。只管模型如多线程、线程池和 I/O 多路复用提供了并发解决的基础架构,但它们各自都有局限性,特地是在高并发和低提早要求的场景中。

这些挑战促使了对一种更高效、更可扩大的并发解决模式的需要— 这就是 Reactor 模式。Reactor 模式采纳事件驱动的办法,联合同步 I/O 多路复用技术,如 select、poll 或 epoll,提供了一种不同于传统线程模型的并发解决机制。

为什么须要 Reactor 模式?

并发和 I/O 效率:传统的多线程和多过程模型在解决成千上万的并发连贯时可能会遇到性能瓶颈。这些模型往往波及重的上下文切换和资源分配,特地是在频繁的 I/O 操作下。

简化事件处理:在 I/O 多路复用模型中,尽管能够高效地监控多个 I/O 流,但在事件散发和解决方面往往不足组织和构造。Reactor 模式提供了一种清晰的框架来解决多个并发 I/O 事件,简化了事件驱动程序的开发。

Reactor 模式详解

Reactor 是什么?

Reactor 模式能够了解为一种在网络编程中罕用的设计模式,用于高效地解决多个并发 I/O 事件,如用户申请或网络通信。它的外围概念是应用一个中心化的处理器(称为 Reactor)来监控所有的 I/O 申请。当一个 I/O 事件产生时(例如,新的客户端连贯或者数据达到),Reactor 会捕捉这个事件,并将其分派给相应的处理程序进行解决。

外围组件:

1.Handles (句柄):

定义 :句柄是对操作系统资源的援用,通常是 文件描述符(file descriptor)。在网络编程中,这通常是指代网络套接字(sockets)。

用处:它用于标识一个特定的网络连接或其余 I/O 资源,如关上的文件、管道等。

示例:当一个客户端连贯到服务器,服务器会为这个连贯创立一个套接字,并为其调配一个文件描述符,这个文件描述符就是一个句柄。

2.Synchronous Event Demultiplexer (事件多路散发器):

定义:事件多路散发器是负责期待多个句柄上事件产生的组件。它能够同时监控多个句柄,如网络套接字上的可读或可写事件。

实现:在 Linux 中,这通常通过零碎调用如 select, poll 或 epoll 实现。

性能:当一个或多个句柄上产生事件时(例如,新的客户端连贯、数据达到等),事件多路散发器告诉 Reactor。

3.Event Handler (事件处理器):

定义: 它是一个定义了解决不同类型事件所需接口或协定的抽象概念。通常蕴含一系列的办法或函数,用于解决各种事件,如读取数据(可读事件)、写入数据(可写事件)或处理错误(谬误事件)。事件处理器定义了在产生特定事件时该当调用哪些办法,但不波及这些办法的具体实现。

例如: 一个事件处理器接口可能有一个 handle_read 办法用于解决可读事件,但它并不实现该办法。

4.Concrete Event Handlers (具体事件处理器):

定义: 具体事件处理器实现了定义在事件处理器接口中的所有办法,提供了如何解决特定事件的具体逻辑。

例如,一个具体事件处理器可能实现 handle_read 办法来从套接字中读取数据并解决这些数据。又或者实现 handle_accept 办法来解决客户端的连贯申请。

具体事件处理器是理论工作的组件,每个具体的事件处理器实例通常与应用程序中的一个特定资源(一个 socket 文件描述符)关联。

5.Initiation Dispatcher (初始化散发器):

定义:初始化散发器是 Reactor 模式的外围组件,负责管理事件循环、监听事件并将它们散发到相应的具体事件处理器。

职责:它初始化事件多路散发器,注册事件处理器,并在事件产生时调用相应的具体事件处理器。

事件循环:在整个应用程序的生命周期内,初始化散发器运行一个循环,期待和散发事件。

select 实现的 Reactor 网络服务器程序

这里只是提供一个简略示例,但以上的 5 个组件都蕴含。

#define PORT 8080
#define BUFFER_SIZE 1024
#define MAX_CLIENTS 1024

handler_t handlers[MAX_CLIENTS];
int num_clients = 0;

typedef struct handler_t {
    int handle; // 句柄:在咱们的上下文中就是套接字描述符。void (*handle_func)(int handle, void *arg);  // 事件处理器
} handler_t;

// 具体事件处理器:解决客户端的连贯申请
void acceptor_handler_func(int handle, void *arg) {
    struct sockaddr_in client_addr;
    socklen_t client_len = sizeof(client_addr);
    int client_fd = accept(handle, (struct sockaddr*)&client_addr, &client_len);
    if (client_fd < 0) { }
    printf("Accepted connection from %s\n", inet_ntoa(client_addr.sin_addr));
    // 将新客户端退出到 handlers 中
    handlers[num_clients].handle = client_fd;
    handlers[num_clients].handle_func = client_handler_func;
    num_clients++;
}

// 具体事件处理器:解决客户端的数据处理申请
void client_handler_func(int handle, void *arg) {char buffer[BUFFER_SIZE];
    int nbytes = recv(handle, buffer, sizeof(buffer), 0);

    if (nbytes <= 0) {close(handle);
        // 将 handle 从 handlers 数组中移除
        for (int i = 0; i < num_clients; i++) {if (handlers[i].handle == handle) {handlers[i] = handlers[num_clients - 1];
                num_clients--;
                break;
            }
        }
    } else {send(handle, buffer, nbytes, 0);
    }
}

void event_demultiplexer() {
    fd_set read_fds;
    int fd_max = 0;

    FD_ZERO(&read_fds);
    for (int i = 0; i < num_clients; i++) {FD_SET(handlers[i].handle, &read_fds);
        if (handlers[i].handle > fd_max) {fd_max = handlers[i].handle;
        }
    }
    // 期待 socket 上的可读事件
    if (select(fd_max + 1, &read_fds, NULL, NULL, NULL) == -1) {perror("select");
        exit(1);
    }

    // 散发事件
    for (int i = 0; i < num_clients; i++) {if (FD_ISSET(handlers[i].handle, &read_fds)) {handlers[i].handle_func(handlers[i].handle, NULL);
        }
    }
}

/*
Initiation Dispatcher (初始化散发器)
Initiation Dispatcher 是 Reactor 模式的外围,容许应用程序注册事件、登记事件。并且它负责启动事件循环,期待事件并散发事件。*/

void run_reactor(int listen_fd) {
    // 注册事件
    handlers[num_clients].handle = listen_fd;
    handlers[num_clients].handle_func = acceptor_handler_func;
    num_clients++;
    
    // 启动事件循环
    while (1) {event_demultiplexer();
    }
}

int main() {
    int listen_fd;
    struct sockaddr_in server_addr;
    listen_fd = socket(AF_INET, SOCK_STREAM, 0)
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_port = htons(PORT);
    server_addr.sin_addr.s_addr = INADDR_ANY;

    bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr);
    listen(listen_fd, 10);
  
    run_reactor(listen_fd);
    close(listen_fd);
    return 0;
}

基于 epoll 的高效性,咱们个别会基于 epoll 去实现 reactor。具体实现可参考这篇文章:
https://zhuanlan.zhihu.com/p/539556726

Reactor 事件处理流程

上面通过时序图来图示上述代码的执行过程,不便大家了解:

Reactor 模式的劣势和利用场景

Reactor 模式的次要劣势包含:

  • 高效的资源利用
    通过单线程或大量线程来治理多个并发连贯,缩小了线程上下文切换和资源耗费,进步了资源利用效率。
  • 疾速响应能力
    非阻塞和事件驱动机制确保了疾速响应内部事件,进步了程序的响应速度。
  • 更好的可扩展性
    可能解决成千上万的并发连贯,而不会遇到传统多线程或多过程模型中线程资源限度的问题。
利用场景

这种模式特地适宜于须要高并发解决的网络服务器,如 Web 服务器、数据库服务器等。

论断:
Reactor 模式是古代高性能网络编程的基石之一。它通过事件驱动和非阻塞 I/O 机制无效地解决了传统并发模型在高并发环境下的限度,为构建可扩大的网络应用提供了弱小的工具。

Proactor 模式详解

在后面的解说中,咱们探讨了 Reactor 模式。该模式次要依赖于同步 I/O,然而,随着并发需要的减少,尤其在高负载环境下,同步 I/O 的局限性逐步凸显。

应答这一挑战,异步编程模型的 Proactor 模式 提供了一种全新的解决方案。它区别于 Reactor 的同步期待,转而采纳 齐全异步的 I/O 操作 。在这个模式下,应用程序无需在 I/O 实现前期待,而是在 I/O 实现后接管告诉。Proactor 模式无效缩小了等待时间,进步了对并发连贯的解决效率,尤其适宜于构建 高性能 I/O 密集型 的网络应用。这一模式不仅晋升了性能,也代表了网络编程范式的一次重要转变,为开发高效和可扩大的网络服务提供了新思路。

Proactor 是什么?

Proactor 模式是一种高级的异步编程模型,用于解决 I/O 操作。与传统的同步 I/O 操作(如 Reactor 模式)不同,Proactor 模式容许应用程序在不阻塞主执行线程的状况下执行 I/O 操作。应用程序发动异步 I/O 申请后能够继续执行其余工作,而无需阻塞期待 I/O 操作的实现。当 I/O 操作理论实现时,操作系统会告诉应用程序,并触发预约义的回调函数或事件处理程序来解决 I/O 操作的后果。

外围组件

异步操作对象: 该对象代表了单个的异步 I/O 操作,如异步读取或写入。它 们通常封装了操作的细节,如操作类型、指标资源(文件描述符)、缓冲区地址等。

异步操作对象的定义

enum {
    ADD_TYPE_ACCEPT,
    ADD_TYPE_READ,
    ADD_TYPE_WRITE
};

struct io_data {
    int type; // ADD_TYPE_ACCEPT, ADD_TYPE_READ, ADD_TYPE_WRITE 等
    int fd;
    size_t bytes_read;
    char buffer[BUFFER_SIZE];
}; 

Proactor 初始器(Proactor Initiator)
Proactor 初始器是负责启动和配置异步 I/O 操作流程的组件。它通常由用户空间的代码执行,负责筹备和提交异步 I/O 申请到内核。

在 io_uring 中,Proactor 初始器 对应的是用户空间代码,特地是负责初始化 io_uring 实例、以及提交异步 I/O 申请到内核的逻辑。

来看下在 io_uring 中,Proactor Initiator 的代码示例:

// 初始化 io_uring 实例
int ret = io_uring_queue_init(256, &ring, 0);

// 提交一个异步读取申请
static void submit_async_read(struct io_uring *ring, int fd) {struct io_data *data = malloc(sizeof(struct  io_data));
   struct io_uring_sqe *sqe  = io_uring_get_sqe(ring); 
    data->fd = fd;
    data->type = ADD_TYPE_READ;
    io_uring_prep_read(sqe, fd, data->buffer, BUFFER_SIZE, 0);      // 筹备读取申请
    io_uring_sqe_set_data(sqe, data); // 设置用户数据
    io_uring_submit(ring);  // 提交申请
}

异步操作处理器(Asynchronous Operation Processor)
异步操作处理器是 Proactor 模式的外围,在内核中执行,负责启动异步 I/O 操作并在操作实现时告诉用户空间的 Proactor 实例。

在 io_uring 中,异步操作处理器 实际上是 io_uring 的内核组件。这包含提交队列(SQ)和实现队列(CQ),以及内核中负责解决这些队列的逻辑。

实现处理器(Completion Handler)

实现处理器是由利用程序定义的回调函数,它们在异步 I/O 操作实现时被调用以解决 I/O 操作的后果。

在 io_uring 中,实现处理器 对应于那些被提交到 io_uring 并在 I/O 操作实现后执行的回调函数。这些回调函数解决 io_uring 从实现队列中获取的 CQEs(多个实现队列条目)。

异步读取回调函数代码示例:

void handle_read(struct io_uring *ring, struct io_uring_cqe *cqe) {struct io_data *data = io_uring_cqe_get_data(cqe);
    if (!data) {return;  // 数据为空则间接返回}

    if (cqe->res <= 0) {
        // 客户端断开连接或读取谬误
        close(data->fd);
    } else {// 读取数据,筹备回写(data->buffer 缓冲区曾经有数据了)
        data->bytes_read = cqe->res;
        printf("Received: %.*s\n", (int)data->bytes_read, data->buffer);
        // 能够在这里增加写回逻辑
    }
    free(data);  // 开释内存
}

Proactor 实例
Proactor 实例是负责管理整个异步 I/O 流程的组件,它治理着异步操作处理器和实现处理器,调度实现处理器,并解决所有的异步事件。

在 io_uring 中,Proactor 实例 对应的是用户空间中保护 io_uring 接口和逻辑的局部,其实就是一个事件循环,它负责监控实现队列(CQ),确定哪些 I/O 操作曾经实现,并触发相应的实现处理器。

Proactor 实例代码示例:

// 运行事件循环以解决异步 I/O 操作
void run_io_uring_loop(struct io_uring *ring) {
    struct io_uring_cqe *cqe;
    unsigned head;

    while (1) {
        // 提交所有挂起的申请并期待至多一个申请实现
        io_uring_submit_and_wait(ring, 1);

        // 解决所有已实现的事件
        io_uring_for_each_cqe(ring, head, cqe) {if (cqe->res < 0) {fprintf(stderr, "IO operation failed: %s\n", strerror(-cqe->res));
            } else {handle_read(ring, cqe); // 调用实现处理器
            }

            // 标记该事件已解决
            io_uring_cqe_seen(ring, cqe);
        }
    }
}

Proactor 事件处理流程

启动异步操作(Proactor 初始器)

你的程序(通过 Proactor 初始器)筹备一个异步 I/O 操作,比如说读取文件或接管网络数据。这个筹备过程波及指定要进行的操作类型(例如读取或写入)、哪个文件或网络连接,以及数据寄存的地位。

一旦筹备好,这个异步操作被提交给操作系统。如果应用 io_uring,这意味着将操作申请放入 io_uring 的提交队列(SQ)。操作系统内核解决异步操作(由异步操作处理器执行)。

一旦异步操作被提交,操作系统接管这个工作。在 io_uring 中,内核会解决这些 I/O 申请。与此同时,你的程序能够继续执行其余工作,不用期待 I/O 操作实现。

告诉 Proactor 实例

当操作系统实现了一个异步 I/O 操作,它会将此操作的后果放入实现队列(CQ)。

你的程序中的 Proactor 实例会定期检查这个实现队列,看看是否有任何操作曾经实现。

解决实现的操作(实现处理器):

对于每一个曾经实现的操作,Proactor 实例会调用相应的实现处理器。实现处理器是你当时定义好的,专门用来解决异步操作实现后的数据的函数。比如说,如果操作对象是网络套接字,处理器可能会解决读取到的数据。

清理和筹备下一步操作

一旦实现处理器运行结束,Proactor 实例会进行必要的清理工作,并筹备接管和解决更多的实现事件。

总结一下:Proactor 模式容许你的程序异步地执行 I/O 操作,同时持续进行其余工作。操作系统在后盾解决这些 I/O 申请,当它们实现时,你的程序会失去告诉,并调用相应的回调函数来处理结果。这个过程优化了资源的应用,进步了应用程序的响应性和效率。

Proactor 模式的劣势

齐全的异步解决:

Proactor 模式实现了真正的异步 I/O。在 Proactor 模式中,所有的 I/O 操作(包含读写)都是异步实现的。这意味着应用程序能够在 I/O 操作进行时继续执行其余工作,而无需期待 I/O 操作的实现。
相比之下,Reactor 模式通常只能异步地解决 I/O 申请的筹备阶段(例如期待数据达到或可发送状态),而理论的读写操作依然是同步进行的。

缩小线程阻塞:

在 Proactor 模式中,因为 I/O 操作齐全异步,应用程序线程不会因期待 I/O 操作而阻塞,这对于放弃高性能和响应性是十分重要的。
Reactor 模式尽管缩小了间接的 I/O 期待(例如期待数据达到),但在解决数据时依然可能呈现阻塞(如:数据处理操作耗时较长)。

简化编程模型:

Proactor 模式通过预约义的回调或事件处理器简化了异步 I/O 的编程模型,使得代码更加清晰和易于保护。
在 Reactor 模式中,编程者须要显式解决 I/O 事件的散发和响应,可能导致更简单的事件处理逻辑。

Proactor 模式的利用场景

高性能网络服务器

如 Web 服务器、数据库服务器等,特地是在须要解决大量并发网络申请的场景。

文件 I/O 密集型利用

例如日志解决、大数据分析,以及任何须要频繁读写大型文件的利用。

总结

在本系列文章中,咱们深入探讨了 Linux 下的套接字编程,一个在网络通信中不可或缺的核心技术 -套接字。套接字作为网络通信的基石,使得不同主机间的数据交换变得可能。

套接字的实质

咱们首先解析了套接字的概念,它是反对 TCP/IP 网络通信的根底 API,为应用层与网络层之间提供了一个形象层。通过套接字,应用程序能够不必关怀底层的网络细节,就能进行网络通信。

套接字类型

接着,咱们探讨了 套接字的三种根本类型

  • 流式套接字(SOCK_STREAM):提供序列化的、牢靠的、双向的连贯通信。
  • 数据报套接字(SOCK_DGRAM):提供非连贯的、不牢靠的通信。
  • 原始套接字(SOCK_RAW):容许间接拜访底层协定,用于须要细粒度管制的场景。

要害 API 与构造

咱们具体介绍了套接字编程中的要害 API,如 socket、bind、listen、accept、connect 以及 send 和 recv 函数,以及套接字地址构造(如 sockaddr)和地址转换 API,这些是进行套接字编程的根底。

数据处理

字节序转换 API 的探讨,帮忙咱们解决跨平台的数据一致性问题。

Linux 的 IO 模型

本系列文章还笼罩了 Linux 零碎中的多种 IO 模型,包含阻塞 IO、非阻塞 IO、I/ O 多路复用、信号驱动 IO 和异步 IO,它们各有劣势,实用于不同的场景。

网络 I / O 性能优化

在网络 I / O 性能优化局部,咱们探讨了 硬件优化和软件优化 策略,强调了利用程序设计的重要性和内核参数调整的作用。

服务器模型

最初,咱们探讨了 Linux 环境下常见的服务器模型,包含单过程、多过程、多线程、线程池和 I / O 多路复用模型以及异步 I / O 服务器模型,每种模型都有其利用场景和优缺点。

架构模式

服务器架构模式,如 ReactorProactor,提供了高效解决并发网络事件的办法,是构建高性能网络应用的要害。

至此: 咱们曾经摸索了 Linux 网络编程的外围畛域,涵盖了从根本套接字类型与 API 的应用,到简单的 I/O 模型和服务器架构设计等要害知识点。这些内容形成了搭建高可用网络服务的根底框架。本篇文章次要是帮忙大家提供一个清晰的 Linux 网络编程学习指南,心愿这篇文章可能为你们学习编程提供帮忙。

如果你对 Linux 网络编程 有更深的趣味,或者 想要摸索更多对于 Linux 编程、以及计算机根底相干的常识,无妨关注我的公众号「跟着小康学编程」。这里不仅有丰盛的学习资源,还有继续更新的技术文章。

另外,小康最近创立了一个技术交换群,专门用来探讨技术相干或者解答读者的问题。大家在浏览这篇文章的时候,如果感觉有问题的或者有不了解的知识点,欢送大家加群或者评论区询问。我可能解决的,尽量给大家回复。

本文由 mdnice 多平台公布

退出移动版