Linux网络编程

网络基础

协议的概念

协议,某种意义上可以理解为“规则”,是数据传输和数据解释的规则。

一个简单的例子:

  • A、B之间传输文件,规定:

    • 第一次,发送方传输文件名,接收方收到后应答OK给发送方
    • 第二次,发送方传输文件的size,接收方收到数据后再次应答OK给发送方
    • 第三次,发送方传输文件内容,接收方数据接收完成后,应答OK给发送方

由此,A、B直接可以通过三次数据传输来传递任何文件。这就在A、B之间形成了一个简单的数据传输规则。A、B之间达成的这种相互遵守、相互理解的规则即为协议。

这种仅在A、B之间遵守的协议称之为原始协议。当此协议被更多的人采用,不断的增加、改进、维护、完善,最终形成一个稳定的、完整的文件传输协议,被广泛应用于各种文件传输过程,该协议就成为一个标准协议,最早的FTP协议就是如此产生。

TCP协议注重数据的传输,HTTP协议注重数据的解释。

典型协议

传输层:TCP/UDP

应用层:HTTP,FTP

网络层:IP、ICMP、IGMP

网络接口层:ARP、RARP

TCP传输控制协议(Transmission Control Protocol)是一种面向连接的、可靠的、基于字节流的传输层通信协议。

UDP用户数据报协议(User Datagram Protocol)是OSI参考模型中一种无连接的传输层协议,提供面向事务的简单不可靠信息传送服务。

HTTP超文本传输协议(Hyper Text Transfer Protocol)是互联网上应用最为广泛的一种网络协议

FTP文件传输协议(File Transfer Protocol)

IP协议是因特网互联协议(Internet Protocol)

ICMP协议是Internet控制报文协议(Internet Control Message Protocol)它是TCP/IP协议族的一个子协议,用于在IP主机路由器之间传递控制消息。

IGMP协议是 Internet 组管理协议(Internet Group Management Protocol),是因特网协议家族中的一个组播协议。该协议运行在主机和组播路由器之间。

ARP协议是正向地址解析协议(Address Resolution Protocol),通过已知的IP,寻找对应主机的MAC地址

RARP是反向地址转换协议,通过MAC地址确定IP地址。

网络应用程序设计模式

  • C/S模式

    • 传统的网络应用设计模式,客户机(client)/服务器(server)模式。需要在通讯两端各自部署客户机和服务器来完成数据通信。
  • B/S模式

    • 浏览器(browser)/服务器(server)模式。只需在一端部署服务器,而另外一端使用每台PC都默认配置的浏览器即可完成数据的传输。
  • 优缺点

    • C/S

      • 对于C/S模式来说,其优点明显。客户端位于目标主机上可以保证性能,将数据缓存至客户端本地,从而提高数据传输效率。且,一般来说客户端和服务器程序由一个开发团队创作,所以他们之间所采用的协议相对灵活。可以在标准协议的基础上根据需求裁剪及定制。例如,腾讯公司所采用的通信协议,即为ftp协议的修改剪裁版。
      • C/S模式的缺点也较突出。由于客户端和服务器都需要有一个开发团队来完成开发。工作量将成倍提升,开发周期较长。另外,从用户角度出发,需要将客户端安插至用户主机上,对用户主机的安全性构成威胁。这也是很多用户不愿使用C/S模式应用程序的重要原因。
    • B/S

      • B/S模式相比C/S模式而言,由于它没有独立的客户端,使用标准浏览器作为客户端,其工作开发量较小。只需开发服务器端即可。另外由于其采用浏览器显示数据,因此移植性非常好,不受平台限制。如早期的偷菜游戏,在各个平台上都可以完美运行。
      • B/S模式的缺点也较明显。由于使用第三方浏览器,因此网络应用支持受限。另外,没有客户端放到对方主机上,缓存数据不尽如人意,从而传输数据量受到限制。应用的观感大打折扣。第三,必须与浏览器一样,采用标准http协议进行通信,协议选择不灵活

分层模型

OSI七层模型

image

  1. 物理层:主要定义物理设备标准,如网线的接口类型、光纤的接口类型、各种传输介质的传输速率等。它的主要作用是传输比特流(就是由1、0转化为电流强弱来进行传输,到达目的地后再转化为1、0,也就是我们常说的数模转换与模数转换)。这一层的数据叫做比特。
  2. 数据链路层:定义了如何让格式化数据以帧为单位进行传输,以及如何让控制对物理介质的访问。这一层通常还提供错误检测和纠正,以确保数据的可靠传输。如:串口通信中使用到的115200、8、N、1
  3. 网络层:在位于不同地理位置的网络中的两个主机系统之间提供连接和路径选择。Internet的发展使得从世界各站点访问信息的用户数大大增加,而网络层正是管理这种连接的层。
  4. 传输层:定义了一些传输数据的协议和端口号(WWW端口80等),如:TCP(传输控制协议,传输效率低,可靠性强,用于传输可靠性要求高,数据量大的数据),UDP(用户数据报协议,与TCP特性恰恰相反,用于传输可靠性要求不高,数据量小的数据,如QQ聊天数据就是通过这种方式传输的)。主要是将从下层接收的数据进行分段和传输,到达目的地址后再进行重组。常常把这一层数据叫做段。
  5. 会话层:通过传输层(端口号:传输端口与接收端口)建立数据传输的通路。主要在你的系统之间发起会话或者接受会话请求(设备之间需要互相认识可以是IP也可以是MAC或者是主机名)。
  6. 表示层:可确保一个系统的应用层所发送的信息可以被另一个系统的应用层读取。例如,PC程序与另一台计算机进行通信,其中一台计算机使用扩展二一十进制交换码(EBCDIC),而另一台则使用美国信息交换标准码(ASCII)来表示相同的字符。如有必要,表示层会通过使用一种通格式来实现多种数据格式之间的转换。
  7. 应用层:是最靠近用户的OSI层。这一层为用户的应用程序(例如电子邮件、文件传输和终端仿真)提供网络服务。

TCP/IP四层模型

TCP/IP网络协议栈分为应用层(Application)、传输层(Transport)、网络层(Network)和链路层(Link)四层。如下图所示:

image

一般在应用开发过程中,讨论最多的是TCP/IP模型。

通信过程

两台计算机通过TCP/IP协议通讯的过程如下所示:

image

上图对应两台计算机在同一网段中的情况,如果两台计算机在不同的网段中,那么数据从一台计算机到另一台计算机传输过程中要经过一个或多个路由器,如下图所示:

image

链路层有以太网、令牌环网等标准,链路层负责网卡设备的驱动、帧同步(即从网线上检测到什么信号算作新帧的开始)、冲突检测(如果检测到冲突就自动重发)、数据差错校验等工作。交换机是工作在链路层的网络设备,可以在不同的链路层网络之间转发数据帧(比如十兆以太网和百兆以太网之间、以太网和令牌环网之间),由于不同链路层的帧格式不同,交换机要将进来的数据包拆掉链路层首部重新封装之后再转发。

网络层的IP协议是构成Internet的基础。Internet上的主机通过IP地址来标识,Internet上有大量路由器负责根据IP地址选择合适的路径转发数据包,数据包从Internet上的源主机到目的主机往往要经过十多个路由器。路由器是工作在第三层的网络设备,同时兼有交换机的功能,可以在不同的链路层接口之间转发数据包,因此路由器需要将进来的数据包拆掉网络层和链路层两层首部并重新封装。IP协议不保证传输的可靠性,数据包在传输过程中可能丢失,可靠性可以在上层协议或应用程序中提供支持。

网络层负责点到点(ptop,point-to-point)的传输(这里的“点”指主机或路由器),而传输层负责端到端(etoe,end-to-end)的传输(这里的“端”指源主机和目的主机)。传输层可选择TCP或UDP协议。

TCP是一种面向连接的、可靠的协议,有点像打电话,双方拿起电话互通身份之后就建立了连接,然后说话就行了,这边说的话那边保证听得到,并且是按说话的顺序听到的,说完话挂机断开连接。也就是说TCP传输的双方需要首先建立连接,之后由TCP协议保证数据收发的可靠性,丢失的数据包自动重发,上层应用程序收到的总是可靠的数据流,通讯之后关闭连接。

UDP是无连接的传输协议,不保证可靠性,有点像寄信,信写好放到邮筒里,既不能保证信件在邮递过程中不会丢失,也不能保证信件寄送顺序。使用UDP协议的应用程序需要自己完成丢包重发、消息排序等工作。

目的主机收到数据包后,如何经过各层协议栈最后到达应用程序呢?其过程如下图所示:

image

以太网驱动程序首先根据以太网首部中的“上层协议”字段确定该数据帧的有效载荷(payload,指除去协议首部之外实际传输的数据)是IP、ARP还是RARP协议的数据报,然后交给相应的协议处理。假如是IP数据报,IP协议再根据IP首部中的“上层协议”字段确定该数据报的有效载荷是TCP、UDP、ICMP还是IGMP,然后交给相应的协议处理。假如是TCP段或UDP段,TCP或UDP协议再根据TCP首部或UDP首部的“端口号”字段确定应该将应用层数据交给哪个用户进程。IP地址是标识网络中不同主机的地址,而端口号就是同一台主机上标识不同进程的地址,IP地址和端口号合起来标识网络中唯一的进程。

虽然IP、ARP和RARP数据报都需要以太网驱动程序来封装成帧,但是从功能上划分,ARP和RARP属于链路层,IP属于网络层。虽然ICMP、IGMP、TCP、UDP的数据都需要IP协议来封装成数据报,但是从功能上划分,ICMP、IGMP与IP同属于网络层,TCP和UDP属于传输层。

协议格式

数据包封装

传输层及其以下的机制由内核提供,应用层由用户进程提供(后面将介绍如何使用socket API编写应用程序),应用程序对通讯数据的含义进行解释,而传输层及其以下处理通讯的细节,将数据从一台计算机通过一定的路径发送到另一台计算机。应用层数据通过协议栈发到网络上时,每层协议都要加上一个数据首部(header),称为封装(Encapsulation),如下图所示:

image

不同的协议层对数据包有不同的称谓,在传输层叫做段(segment),在网络层叫做数据报(datagram),在链路层叫做帧(frame)。数据封装成帧后发到传输介质上,到达目的主机后每层协议再剥掉相应的首部,最后将应用层数据交给应用程序处理。

以太网帧格式

以太网的帧格式如下所示:

image

其中的源地址和目的地址是指网卡的硬件地址(也叫MAC地址),长度是48位,是在网卡出厂时固化的。可在shell中使用ifconfig命令查看,“HWaddr 00:15:F2:14:9E:3F”部分就是硬件地址。协议字段有三种值,分别对应IP、ARP、RARP。帧尾是CRC校验码。

以太网帧中的数据长度规定最小46字节,最大1500字节,ARP和RARP数据包的长度不够46字节,要在后面补填充位。最大值1500称为以太网的最大传输单元(MTU) ,不同的网络类型有不同的MTU,如果一个数据包从以太网路由到拨号链路上,数据包长度大于拨号链路的MTU,则需要对数据包进行分片(fragmentation)。ifconfig命令输出中也有“MTU:1500”。注意,MTU这个概念指数据帧中有效载荷的最大长度,不包括帧头长度。

ARP数据报格式

在网络通讯时,源主机的应用程序知道目的主机的IP地址和端口号,却不知道目的主机的硬件地址,而数据包首先是被网卡接收到再去处理上层协议的,如果接收到的数据包的硬件地址与本机不符,则直接丢弃。因此在通讯前必须获得目的主机的硬件地址。ARP协议就起到这个作用。源主机发出ARP请求,询问“IP地址是192.168.0.1的主机的硬件地址是多少”,并将这个请求广播到本地网段(以太网帧首部的硬件地址填FF:FF:FF:FF:FF:FF表示广播),目的主机接收到广播的ARP请求,发现其中的IP地址与本机相符,则发送一个ARP应答数据包给源主机,将自己的硬件地址填写在应答包中。

每台主机都维护一个ARP缓存表,可以用arp -a命令查看。缓存表中的表项有过期时间(一般为20分钟),如果20分钟内没有再次使用某个表项,则该表项失效,下次还要发ARP请求来获得目的主机的硬件地址。想一想,为什么表项要有过期时间而不是一直有效?

ARP数据报的格式如下所示:

image

源MAC地址、目的MAC地址在以太网首部和ARP请求中各出现一次,对于链路层为以太网的情况是多余的,但如果链路层是其它类型的网络则有可能是必要的。硬件类型指链路层网络类型,1为以太网,协议类型指要转换的地址类型,0x0800为IP地址,后面两个地址长度对于以太网地址和IP地址分别为6和4(字节),op字段为1表示ARP请求,op字段为2表示ARP应答。

一个具体的例子

请求帧如下(为了清晰在每行的前面加了字节计数,每行16个字节):

  • 以太网首部(14字节)

0000: ff ff ff ff ff ff 00 05 5d 61 58 a8 08 06

  • ARP帧(28字节)

0000: 00 01
0010: 08 00 06 04 00 01 00 05 5d 61 58 a8 c0 a8 00 37
0020: 00 00 00 00 00 00 c0 a8 00 02

  • 填充位(18字节)

0020: 00 77 31 d2 50 10
0030: fd 78 41 d3 00 00 00 00 00 00 00 00

请求帧的解释

  • 以太网首部:

    • 目的主机采用广播地址:ff:ff:ff:ff:ff:ff,因为不知道目的主机MAC地址
    • 源主机的MAC地址:00:05:5d:61:58:a8
    • 上层协议类型:0x0806,表示:ARP。
  • ARP帧:
    • 硬件类型:0x0001,表示:以太网
    • 协议类型:0x0800,表示:IP协议
    • 硬件地址(MAC地址)长度:6
    • 协议地址(IP地址)长度:4
    • op(option)值:0x0001,表示:请求目的主机的MAC地址
    • 源主机MAC地址:00:05:5d:61:58:a8
    • 源主机IP地址:c0 a8 00 37(192.168.0.55)
    • 目的主机MAC地址全0待填写(协议请求的就是这个)
    • 目的主机IP地址:c0 a8 00 02(192.168.0.2)。
  • 填充位:由于以太网规定最小数据长度为46字节,ARP帧长度只有28字节,因此有18字节填充位,填充位的内容没有定义,与具体实现相关。

由于以太网规定最小数据长度为46字节,ARP帧长度只有28字节,因此有18字节填充位,填充位的内容没有定义,与具体实现相关。

应答帧如下

  • 以太网首部

0000: 00 05 5d 61 58 a8 00 05 5d a1 b8 40 08 06

  • ARP帧

0000: 00 01

0010: 08 00 06 04 00 02 00 05 5d a1 b8 40 c0 a8 00 02

0020: 00 05 5d 61 58 a8 c0 a8 00 37

  • 填充位

0020: 00 77 31 d2 50 10

0030: fd 78 41 d3 00 00 00 00 00 00 00 00

应答帧解释

  • 以太网首部:

    • 目的主机MAC地址:00 05 5d 61 58 a8
    • 源主机MAC地址:00 05 5d a1 b8 40
    • 上层协议类型:08 06,表示ARP协议
  • ARP帧:

    • 硬件类型:0x0001,表示以太网
    • 协议类型:0x0800,表示IP协议
    • 硬件地址(MAC地址)长度:6
    • 协议地址(IP地址)长度:4
    • op(option):0x0002,表示应答
    • 发送端以太网地址(源主机MAC地址):00 05 5d a1 b8 40。应答帧的源主机MAC地址,就是请求帧请求的目的主机MAC地址,也是ARP协议的目的。
    • 源主机IP地址:c0 a8 00 02
    • 目的主机MAC地址:00 05 5d 61 58 a8
    • 目的主机IP地址:c0 a8 00 37
  • 填充位:由于以太网规定最小数据长度为46字节,ARP帧长度只有28字节,因此有18字节填充位,填充位的内容没有定义,与具体实现相关。

IP段格式

image

IP数据报的首部长度和数据长度都是可变长的,但总是4字节的整数倍。对于IPv4,4位版本字段是4。4位首部长度的数值是以4字节为单位的,最小值为5,也就是说首部长度最小是4x5=20字节,也就是不带任何选项的IP首部,4位二进制能表示的最大值是15,也就是说首部长度最大是60字节。8位TOS字段有3个位用来指定IP数据报的优先级(目前已经废弃不用),还有4个位表示可选的服务类型(最小延迟、最大吞吐量、最大可靠性、最小成本),还有一个位总是0。总长度是整个数据报(包括IP首部和IP层payload(payload是需要传输的实际数据。))的字节数。每传一个IP数据报,16位的标识加1,可用于分片和重新组装数据报。3位标志和13位片偏移用于分片。TTL(Time to live)是这样用的:源主机为数据包设定一个生存时间,比如64,每过一个路由器就把该值减1,如果减到0就表示路由已经太长了仍然找不到目的主机的网络,就丢弃该包,因此这个生存时间的单位不是秒,而是跳(hop)。协议字段指示上层协议是TCP、UDP、ICMP还是IGMP。然后是校验和,只校验IP首部,数据的校验由更高层协议负责。IPv4的IP地址长度为32位。

UDP数据报格式

image

下面分析一帧基于UDP的TFTP协议帧。

  • 以太网首部

0000: 00 05 5d 67 d0 b1 00 05 5d 61 58 a8 08 00

  • IP首部

0000: 45 00

0010: 00 53 93 25 00 00 80 11 25 ec c0 a8 00 37 c0 a8

0020: 00 01

  • UDP首部

0020: 05 d4 00 45 00 3f ac 40

  • TFTP协议

0020: 00 01 'c'':''\''q'

0030: 'w''e''r''q''.''q''w''e'00 'n''e''t''a''s''c''i'

0040: 'i'00 'b''l''k''s''i''z''e'00 '5''1''2'00 't''i'

0050: 'm''e''o''u''t'00 '1''0'00 't''s''i''z''e'00 '0'

0060: 00

  • 以太网首部:

    • 源MAC地址是00:05:5d:61:58:a8,
    • 目的MAC地址是00:05:5d:67:d0:b1,
    • 上层协议类型0x0800,表示IP协议。
  • IP首部:

    • 第一个字节0x45包含4位版本号和4位首部长度,版本号为4,即IPv4,首部长度为5,说明IP首部不带有选项字段。
    • 服务类型为0x00,没有使用服务。
    • 16位总长度字段(包括IP首部和IP层payload的长度)为0x0053,即83字节。加上以太网首部14字节可知整个帧长度是97字节。
    • IP报标识是0x9325。
    • 标志字段和片偏移字段设置为0x0000,就是DF=0允许分片,MF=0此数据报没有更多分片,没有分片偏移。
    • TTL是0x80,也就是128。
    • 上层协议0x11表示UDP协议。
    • IP首部校验和为0x25ec。
    • 源主机IP是c0 a8 00 37(192.168.0.55)。
    • 目的主机IP是c0 a8 00 01(192.168.0.1)。
  • UDP首部:

    • 源端口号0x05d4(1492)是客户端的端口号。
    • 目的端口号0x0045(69)是TFTP服务的well-known端口号。
    • UDP报长度为0x003f,即63字节,包括UDP首部和UDP层payload(payload是需要传输的实际数据。)的长度。
    • UDP首部和UDP层payload的校验和为0xac40。
  • TFTP是基于文本的协议,各字段之间用字节0分隔,开头的00 01表示请求读取一个文件,接下来的各字段是:

    c:\qwerq.qwe

    netascii

    blksize 512

    timeout 10

    tsize 0

一般的网络通信都是像TFTP协议这样,通信的双方分别是客户端和服务器,客户端主动发起请求(上面的例子就是客户端发起的请求帧),而服务器被动地等待、接收和应答请求。客户端的IP地址和端口号唯一标识了该主机上的TFTP客户端进程,服务器的IP地址和端口号唯一标识了该主机上的TFTP服务进程,由于客户端是主动发起请求的一方,它必须知道服务器的IP地址和TFTP服务进程的端口号,所以,一些常见的网络协议有默认的服务器端口,例如HTTP服务默认TCP协议的80端口,FTP服务默认TCP协议的21端口,TFTP服务默认UDP协议的69端口(如上例所示)。在使用客户端程序时,必须指定服务器的主机名或IP地址,如果不明确指定端口号则采用默认端口,请读者查阅ftp、tftp等程序的man page了解如何指定端口号。/etc/services中列出了所有well-known的服务端口和对应的传输层协议,这是由IANA(Internet Assigned Numbers Authority)规定的,其中有些服务既可以用TCP也可以用UDP,为了清晰,IANA规定这样的服务采用相同的TCP或UDP默认端口号,而另外一些TCP和UDP的相同端口号却对应不同的服务。

很多服务有well-known的端口号,然而客户端程序的端口号却不必是well-known的,往往是每次运行客户端程序时由系统自动分配一个空闲的端口号,用完就释放掉,称为ephemeral的端口号,想想这是为什么?

前面提过,UDP协议不面向连接,也不保证传输的可靠性,例如:

发送端的UDP协议层只管把应用层传来的数据封装成段交给IP协议层就算完成任务了,如果因为网络故障该段无法发到对方,UDP协议层也不会给应用层返回任何错误信息。

接收端的UDP协议层只管把收到的数据根据端口号交给相应的应用程序就算完成任务了,如果发送端发来多个数据包并且在网络上经过不同的路由,到达接收端时顺序已经错乱了,UDP协议层也不保证按发送时的顺序交给应用层。

通常接收端的UDP协议层将收到的数据放在一个固定大小的缓冲区中等待应用程序来提取和处理,如果应用程序提取和处理的速度很慢,而发送端发送的速度很快,就会丢失数据包,UDP协议层并不报告这种错误。

因此,使用UDP协议的应用程序必须考虑到这些可能的问题并实现适当的解决方案,例如等待应答、超时重发、为数据包编号、流量控制等。一般使用UDP协议的应用程序实现都比较简单,只是发送一些对可靠性要求不高的消息,而不发送大量的数据。例如,基于UDP的TFTP协议一般只用于传送小文件(所以才叫trivial的ftp),而基于TCP的FTP协议适用于 各种文件的传输。TCP协议又是如何用面向连接的服务来代替应用程序解决传输的可靠性问题呢。

TCP数据包格式

image

与UDP协议一样也有源端口号和目的端口号,通讯的双方由IP地址和端口号标识。32位序号、32位确认序号、窗口大小稍后详细解释。4位首部长度和IP协议头类似,表示TCP协议头的长度,以4字节为单位,因此TCP协议头最长可以是4x15=60字节,如果没有选项字段,TCP协议头最短20字节。URG、ACK、PSH、RST、SYN、FIN是六个控制位,本节稍后将解释SYN、ACK、FIN、RST四个位,其它位的解释从略。16位检验和将TCP协议头和数据都计算在内。紧急指针和各种选项的解释从略。

TCP协议

TCP通信时序

下图是一次TCP通讯的时序图。TCP连接建立断开。包含三次握手和四次握手。

image

在这个例子中,首先客户端主动发起连接、发送请求,然后服务器端响应请求,然后客户端主动关闭连接。两条竖线表示通讯的两端,从上到下表示时间的先后顺序,注意,数据从一端传到网络的另一端也需要时间,所以图中的箭头都是斜的。双方发送的段按时间顺序编号为1-10,各段中的主要信息在箭头上标出,例如段2的箭头上标着SYN, 8000(0), ACK1001,表示该段中的SYN位置1,32位序号是8000,该段不携带有效载荷(数据字节数为0),ACK位置1,32位确认序号是1001,带有一个mss(Maximum Segment Size,最大报文长度)选项值为1024。

建立连接(三次握手)的过程:

  1. 客户端发送一个带SYN标志的TCP报文到服务器。这是三次握手过程中的段1。

    • 客户端发出段1,SYN位表示连接请求。序号是1000,这个序号在网络通讯中用作临时的地址,每发一个数据字节,这个序号要加1,这样在接收端可以根据序号排出数据包的正确顺序,也可以发现丢包的情况,另外,规定SYN位和FIN位也要占一个序号,这次虽然没发数据,但是由于发了SYN位,因此下次再发送应该用序号1001。mss表示最大段尺寸,如果一个段太大,封装成帧后超过了链路层的最大帧长度,就必须在IP层分片,为了避免这种情况,客户端声明自己的最大段尺寸,建议服务器端发来的段不要超过这个长度。
  2. 服务器端回应客户端,是三次握手中的第2个报文段,同时带ACK标志和SYN标志。它表示对刚才客户端SYN的回应;同时又发送SYN给客户端,询问客户端是否准备好进行数据通讯。

    • 服务器发出段2,也带有SYN位,同时置ACK位表示确认,确认序号是1001,表示“我接收到序号1000及其以前所有的段,请你下次发送序号为1001的段”,也就是应答了客户端的连接请求,同时也给客户端发出一个连接请求,同时声明最大尺寸为1024。
  3. 客户必须再次回应服务器端一个ACK报文,这是报文段3。

    • 客户端发出段3,对服务器的连接请求进行应答,确认序号是8001。在这个过程中,客户端和服务器分别给对方发了连接请求,也应答了对方的连接请求,其中服务器的请求和应答在一个段中发出,因此一共有三个段用于建立连接,称为“三方握手(three-way-handshake)”。在建立连接的同时,双方协商了一些信息,例如双方发送序号的初始值、最大段尺寸等。

在TCP通讯中,如果一方收到另一方发来的段,读出其中的目的端口号,发现本机并没有任何进程使用这个端口,就会应答一个包含RST位的段给另一方。例如,服务器并没有任何进程使用8080端口,我们却用telnet客户端去连接它,服务器收到客户端发来的SYN段就会应答一个RST段,客户端的telnet程序收到RST段后报告错误:

Connection refused:

$ telnet 192.168.0.200 8080

Trying 192.168.0.200...

telnet: Unable to connect to remote host: Connection refused

数据传输的过程:

  1. 客户端发出段4,包含从序号1001开始的20个字节数据。
  2. 服务器发出段5,确认序号为1021,对序号为1001-1020的数据表示确认收到,同时请求发送序号1021开始的数据,服务器在应答的同时也向客户端发送从序号8001开始的10个字节数据,这称为piggyback(Piggyback Acknowledgement(捎带确认):在数据通信中,当接收方收到数据包后,不是立即发送独立的确认消息,而是等待上层有数据要发送时,将确认信息附带在即将发送的数据包中一起发送。这种方式可以有效利用信道带宽,减少独立确认消息的发送次数,提高数据传输效率。)
  3. 客户端发出段6,对服务器发来的序号为8001-8010的数据表示确认收到,请求发送序号8011开始的数据。

在数据传输过程中,ACK和确认序号是非常重要的,应用程序交给TCP协议发送的数据会暂存在TCP层的发送缓冲区中,发出数据包给对方之后,只有收到对方应答的ACK段才知道该数据包确实发到了对方,可以从发送缓冲区中释放掉了,如果因为网络故障丢失了数据包或者丢失了对方发回的ACK段,经过等待超时后TCP协议自动将发送缓冲区中的数据包重发。

关闭连接(四次握手)的过程:

由于TCP连接是全双工的,因此每个方向都必须单独进行关闭。这原则是当一方完成它的数据发送任务后就能发送一个FIN来终止这个方向的连接。收到一个 FIN只意味着这一方向上没有数据流动,一个TCP连接在收到一个FIN后仍能发送数据。首先进行关闭的一方将执行主动关闭,而另一方执行被动关闭。

  1. 客户端发出段7,FIN位表示关闭连接的请求。
  2. 服务器发出段8,应答客户端的关闭连接请求。
  3. 服务器发出段9,其中也包含FIN位,向客户端发送关闭连接请求。
  4. 客户端发出段10,应答服务器的关闭连接请求。

建立连接的过程是三方握手,而关闭连接通常需要4个段,服务器的应答和关闭连接请求通常不合并在一个段中,因为有连接半关闭的情况,这种情况下客户端关闭连接之后就不能再发送数据给服务器了,但是服务器还可以发送数据给客户端,直到服务器也关闭连接为止。

滑动窗口(TCP流量控制)

UDP中存在这样的问题:如果发送端发送的速度较快,接收端接收到数据后处理的速度较慢,而接收缓冲区的大小是固定的,就会丢失数据。TCP协议通过“滑动窗口(Sliding Window)”机制解决这一问题。看下图的通讯过程:

image

  1. 发送端发起连接,声明最大段尺寸(数据部分的最大长度,不包括 TCP 首部(Header)的长度)1460(mss 1460),初始序列号(每个字节的数据都有一个唯一的序列号,序列号是逐字节递增的,建立连接时会产生一个初始序列号。发送SYN,FIN同样会使序列号递增1。)0,窗口大小(窗口大小是接收方告诉发送方“我还能接收多少数据”的一个值。)4096字节。

  2. 接收端应答连接请求,声明最大段尺寸1024,初始序列号8000,窗口大小6144字节。

  3. 发送方应答,三次握手结束。

  4. 发送端发出段4-9,每个段携带1K数据,发送端根据窗口大小知道接收端的缓冲区已满,停止发送数据。

  5. 接收端的程序从缓冲区提走2K数据,接收区有了2K空闲空间,接收端发出段10,在应答已收到6K数据的同时声明窗口大小为2K。

  6. 接收端的程序又从缓冲区提走2K数据,接收区有了4K空闲空间,接收端发出段11,在应答已收到6K数据的同时声明窗口大小为4K。

  7. 发送端发出段12,携带1K数据。

  8. 发送端发出段13,携带1K数据,同时包含FIN位,告诉接收端要关闭连接。

  9. 接收端应答收到的2K数据,以及FIN位,因此应答序列号为8194=6145+2048+1。连接处于半关闭状态,同时声明窗口大小为2K。

  10. 接收端的应用程序提走2K数据,声明窗口大小为4K。

  11. 接收端的应用程序又提走2K数据,声明窗口大小为6K。

  12. 接收端程序已经提走了所有数据,接收缓冲区全空,决定关闭连接。发送段17,包含FIN位。

  13. 发送端应答,连接完全关闭。

上图在接收端用小方块表示1K数据,实心的小方块表示已接收到的数据,虚线框表示接收缓冲区,因此套在虚线框中的空心小方块表示窗口大小,从图中可以看出,随着应用程序提走数据,虚线框是向右滑动的,因此称为滑动窗口。

从这个例子还可以看出,发送端是一K一K地发送数据,而接收端的应用程序可以两K两K地提走数据,当然也有可能一次提走3K或6K数据,或者一次只提走几个字节的数据。也就是说,应用程序所看到的数据是一个整体,或说是一个流(stream),在底层通讯中这些数据可能被拆成很多数据包来发送,但是一个数据包有多少字节对应用程序是不可见的,因此TCP协议是面向流的协议。而UDP是面向消息的协议,每个UDP段都是一条消息,应用程序必须以消息为单位提取数据,不能一次提取任意字节的数据,这一点和TCP是很不同的。

TCP状态转换

下图中的椭圆形框内是使用TCP协议通信的端点的状态,箭头表示状态转换的方向,箭头旁边的描述指的是状态转换过程中的行为(或者理解成这些行为的产生导致了状态的转换)。对于状态转换的理解可以遵循这样的流程:

  • 粗实线表示主动连接端的流程;

  • 粗虚线表示被动连接的流程;

  • 细实线表示特殊情况。

    • SYN_RCVD-->LISTEN​:服务端超时未接收到客户端的ACK,导致三次握手未完成,因此内核发送RST重置监听状态,使得服务端从SYN_RCVD状态转换到LITEN状态。
    • SYN_SENT-->SYN_RCVD​:当客户端发送了 SYN 包(进入 SYN_SENT​ 状态)后,服务器在收到这个 SYN 包之前,也发送了一个自己的 SYN 包。也就是说客户端在发送了SYN之后,未收到服务端的ACK,只收到了服务端的SYN,这会使得客户端再次发送SYN同时发送ACK应答服务端的SYN,并且转换成SYN_RCVD状态。

image

CLOSED: 表示初始状态。

LISTEN: 该状态表示服务器端的某个SOCKET处于监听状态,可以接受连接。

SYN_SENT: 这个状态与SYN_RCVD遥相呼应,当客户端SOCKET执行CONNECT连接时,它首先发送SYN报文,随即进入到了SYN_SENT状态,并等待服务端的发送三次握手中的第2个报文。SYN_SENT状态表示客户端已发送SYN报文。

SYN_RCVD: 该状态表示接收到SYN报文,在正常情况下,这个状态是服务器端的SOCKET在建立TCP连接时的三次握手会话过程中的一个中间状态,很短暂。此种状态时,当收到客户端的ACK报文后,会进入到ESTABLISHED状态。

ESTABLISHED: 表示连接已经建立。

FIN_WAIT_1: FIN_WAIT_1和FIN_WAIT_2状态的真正含义都是表示等待对方的FIN报文。区别是:

FIN_WAIT_1状态是当socket在ESTABLISHED状态时,想主动关闭连接,向对方发送了FIN报文,此时该socket进入到FIN_WAIT_1状态。

FIN_WAIT_2状态是当对方回应ACK后,该socket进入到FIN_WAIT_2状态,正常情况下,对方应马上回应ACK报文,所以FIN_WAIT_1状态一般较难见到,而FIN_WAIT_2状态可用netstat看到。

FIN_WAIT_2:主动关闭链接的一方,发出FIN收到ACK以后进入该状态。称之为半连接或半关闭状态。 该状态下的socket只能接收数据,不能发。

TIME_WAIT: 表示收到了对方的FIN报文,并发送出了ACK报文,等2MSL后即可回到CLOSED可用状态。如果FIN_WAIT_1状态下,收到对方同时带 FIN标志和ACK标志的报文时,可以直接进入到TIME_WAIT状态,而无须经过FIN_WAIT_2状态。

CLOSING: 这种状态较特殊,属于一种较罕见的状态。正常情况下,当你发送FIN报文后,按理来说是应该先收到(或同时收到)对方的 ACK报文,再收到对方的FIN报文。但是CLOSING状态表示你发送FIN报文后,并没有收到对方的ACK报文,反而却也收到了对方的FIN报文。什么情况下会出现此种情况呢?如果双方几乎在同时close一个SOCKET的话,那么就出现了双方同时发送FIN报文的情况,也即会出现CLOSING状态,表示双方都正在关闭SOCKET连接。

CLOSE_WAIT: 此种状态表示在等待关闭。当对方关闭一个SOCKET后发送FIN报文给自己,系统会回应一个ACK报文给对方,此时则进入到CLOSE_WAIT状态。接下来呢,查看是否还有数据发送给对方,如果没有,可以 close这个SOCKET,发送FIN报文给对方,即关闭连接。所以在CLOSE_WAIT状态下,需要关闭连接。

LAST_ACK: 该状态是被动关闭一方在发送FIN报文后,最后等待对方的ACK报文。当收到ACK报文后,即可以进入到CLOSED可用状态。

半关闭

半关闭:当TCP链接中A发送FIN请求关闭,B端回应ACK后(A端进入FIN_WAIT_2状态),B没有立即发送FIN给A时,A方处在半连接状态,此时A可以接收B发送的数据,但是A已不能再向B发送数据。

从程序的角度,可以使用API来控制实现半连接状态。

#include <sys/socket.h>
int shutdown(int sockfd, int how);
sockfd: 需要关闭的socket的描述符
how:	允许为shutdown操作选择以下几种方式:
	SHUT_RD:	关闭sockfd上的读功能,此选项将不允许sockfd进行读操作。
				该套接字不再接受数据,任何当前在套接字接受缓冲区的数据将被无声的丢弃掉。
	SHUT_WR:	关闭sockfd的写功能,此选项将不允许sockfd进行写操作。进程不能在对此套接字发出写操作。
	SHUT_RDWR:	关闭sockfd的读写功能。相当于调用shutdown两次:首先是以SHUT_RD,然后以SHUT_WR。
  • 使用close中止一个连接,它只是减少描述符的引用计数(例如使用dup2函数将其他文件描述符重定位到sockfd),并不直接关闭连接,只有当描述符的引用计数为0时才关闭连接。

  • shutdown不考虑描述符的引用计数,直接关闭描述符。也可选择中止一个方向的连接,只中止读或只中止写。

注意:

  1. 如果有多个进程共享一个套接字,close每被调用一次,计数减1,直到计数为0时,也就是所用进程都调用了close,套接字将被释放。
  2. 在多进程中如果一个进程调用了shutdown(sfd, SHUT_RDWR)后,其它的进程将无法进行通信。但,如果一个进程close(sfd)将不会影响到其它进程。

2MSL

2MSL (Maximum Segment Lifetime) TIME_WAIT状态的存在有两个理由:

  1. 让4次挥手关闭流程更加可靠。 TIME_WAIT 状态确保四次挥手的最后一个 ACK 包被可靠地发送和接收。如果 ACK 包丢失,被动关闭方会重新发送 FIN 包,主动关闭方需要保持 TIME_WAIT 状态,以便重新发送 ACK 包。
  2. 防止lost duplicate(TCP 协议中,"lost duplicate" 指的是由于网络拥塞、路由器故障等原因导致数据包在网络中延迟到达,甚至可能被重复发送的包。这些包可能已经失效,但仍然在网络中存在。)(迷途分组)对后续新建正常链接的传输造成破坏。如果迷途分组和新连接的 socket pair(源 IP、源端口、目标 IP、目标端口)相同,这些迷途分组可能会被误认为是新连接的数据包,从而导致数据混乱(TCP是流式的,所有包到达的顺序是不一致的,依靠序列号由TCP协议栈做顺序的拼接;假设一个incarnation connection这时收到的seq=1000, 来了一个lost duplicate为seq=1000,len=1000, 则TCP认为这个lost duplicate合法,并存放入了receive buffer,导致传输出现错误。),这种情况被称为**Incarnation Connection(Incarnation Connection 是指与之前连接的 socket pair(源 IP、源端口、目标 IP、目标端口)完全相同的新连接。这种连接被称为之前连接的一个实例(incarnation)。)**。TIME_WAIT 状态持续 2MSL 的时间,确保迷途分组在网络中传输的时间足够长,使得TTL为0,最终被丢弃。

该状态为什么设计在主动关闭这一方?

  1. 发送最后ACK的是主动关闭这一方。
  2. 只要有一方保持 TIME_WAIT 状态,就能起到避免incarnation connection在2MSL内的建立。

如何正确对待2MSL TIME_WAIT?

RFC(RFC (Request For Comments),是一系列以编号排定的文件。收集了有关因特网相关资讯,以及UNIX和因特网社群的软件文件。)要求socket pair在处于TIME_WAIT时,不能再起一个incarnation connection。但绝大部分TCP实现强加了更为严格的限制。在2MSL等待期间,socket中使用的本地端口在默认情况下不能再被使用。

若A 10.234.5.5 : 1234​和B 10.55.55.60 : 6666​建立了连接,A主动关闭,那么在A端只要port为1234,无论对方的port和ip是什么,都不允许再起服务。这甚至比RFC限制更为严格,RFC仅仅是要求socket pair不一致,而实现当中只要这个port处于TIME_WAIT,就不允许起连接。这个限制对主动打开方来说是无所谓的,因为一般用的是临时端口;但对于被动打开方,一般是server,就悲剧了,因为server一般是熟知端口。比如http,一般端口是80,不可能允许这个服务在2MSL内不能起来。

解决方案是给服务器的socket设置SO_REUSEADDR选项(端口复用),这样的话就算熟知端口处于TIME_WAIT状态,在这个端口上依旧可以将服务启动。当然,虽然有了SO_REUSEADDR选项,但sockt pair这个限制依旧存在。比如上面的例子,A通过SO_REUSEADDR选项依旧在1234端口上起了监听,但这时我们若是从B通过6666端口去连它,TCP协议会告诉我们连接失败,原因为Address already in use。

RFC 793中规定MSL为2分钟,实际应用中常用的是30秒,1分钟和2分钟等。

程序设计中的问题

首先启动server程序,然后启动client程序连接server,二者连接成功后,使用Ctrl+C终止server程序,然后立即再次启动server程序后,会出现这样一种现象:即client程序无法再与server建立连接。根据Linux版本不同,有些是程序正常启动但是client无法连接,有些则会启动失败出现这样的报错:bind error: Address already in use​。

原因是server程序的终止并没有使TCP协议层的连接完全断开,因此不能再次监听同样的server端口。使用netstat​命令可以查看:

xrain@xue3z:~$netstat -apn | grep 9527
tcp        0      0 127.0.0.1:9527          127.0.0.1:60456         FIN_WAIT2   -
tcp        0      0 127.0.0.1:60456         127.0.0.1:9527          CLOSE_WAIT  2222/nc
  • 位于前面的IP:port​代表TCP协议中的本端,后面的代表TCP协议的对端。9527是server程序绑定的端口,由于server是主动关闭连接的一端,且对端没有关闭socket,因此不会发送FIN给server,所以server处于FIN_WAIT2​状态。

现在使用Ctrl+C终止client程序:

tcp        0      0 127.0.0.1:9527          127.0.0.1:60456         TIME_WAIT   -
  • client程序终止,关闭了自身的socket描述符,server的TCP连接收到client发出的FIN段后处于TIME_WAIT​状态。等待两个MSL(maximum segment lifetime)的时间后才能回到CLOSED状态,因为我们先Ctrl+C终止了server,所以server是主动关闭连接的一方,在TIME_WAIT期间仍然不能再次监听同样的server端口。

端口复用

在server的TCP连接没有完全断开之前不允许重新监听是不合理的。因为,TCP连接没有完全断开指的是connfd(127.0.0.1:6666)没有完全断开,而我们重新监听的是listenfd(0.0.0.0:6666),虽然是占用同一个端口,但IP地址不同,connfd对应的是与某个客户端通讯的一个具体的IP地址,而listenfd对应的是wildcard address。解决这个问题的方法是使用setsockopt()设置socket描述符的选项SO_REUSEADDR为1,表示允许创建端口号相同但IP地址不同的多个socket描述符。

在server代码的socket()和bind()调用之间插入如下代码:

int opt = 1;
setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

有关setsockopt可以设置的其它选项请参考UNP第7章。

TCP异常断开

心跳检测机制

在TCP网络通信中,经常会出现客户端和服务器之间的非正常断开,需要实时检测查询链接状态。常用的解决方法就是在程序中加入心跳机制。

Heart-Beat线程

这个是最常用的简单方法。在接收和发送数据时个人设计一个守护进程(线程),定时发送Heart-Beat包,客户端/服务器收到该小包后,立刻返回相应的包即可检测对方是否实时在线。

该方法的好处是通用,但缺点就是会改变现有的通讯协议!大家一般都是使用业务层心跳来处理,主要是灵活可控。

UNIX网络编程不推荐使用SO_KEEPALIVE​来做心跳检测,还是在业务层以心跳包做检测比较好,也方便控制。

设置TCP属性

SO_KEEPALIVE​保持连接检测对方主机是否崩溃,避免(服务器)永远阻塞于TCP连接的输入。设置该选项后,如果2小时内在此套接口的任一方向都没有数据交换,TCP就自动给对方发一个保持存活探测分节(keepalive probe)。这是一个对方必须响应的TCP分节.它会导致以下三种情况:

  1. 对方接收一切正常:以期望的ACK响应。2小时后,TCP将发出另一个探测分节。
  2. 对方已崩溃且已重新启动:以RST响应。套接口的待处理错误被置为ECONNRESET​,套接口本身则被关闭。
  3. 对方无任何响应:源自berkeley的TCP发送另外8个探测分节,相隔75秒一个,试图得到一个响应。在发出第一个探测分节11分钟15秒后若仍无响应就放弃。套接口的待处理错误被置为ETIMEOUT​,套接口本身则被关闭。如ICMP错误是“host unreachable(主机不可达)”,说明对方主机并没有崩溃,但是不可达,这种情况下待处理错误被置为EHOSTUNREACH​。

根据上面的介绍我们可以知道对端以一种非优雅的方式断开连接的时候,我们可以设置SO_KEEPALIVE属性使得我们在2小时以后发现对方的TCP连接是否依然存在。

int keepAlive = 1;
setsockopt(listenfd, SOL_SOCKET, SO_KEEPALIVE, (void*)&keepAlive, sizeof(keepAlive));

如果我们不能接受如此之长的等待时间,从TCP-Keepalive-HOWTO​上可以知道一共有两种方式可以设置,一种是修改内核关于网络方面的配置参数,另外一种就是SOL_TCP​字段的TCP_KEEPIDLE​, TCP_KEEPINTVL​, TCP_KEEPCNT​三个选项。

1. The tcp\_keepidle parameter specifies the interval of inactivity that causes TCP to generate a KEEPALIVE transmission for an application that requests them. tcp\_keepidle defaults to 14400 (two hours).

/*开始首次KeepAlive探测前的TCP空闭时间 */

  1. The tcp_keepintvl parameter specifies the interval between the nine retriesthat are attempted if a KEEPALIVE transmission is not acknowledged. tcp_keep ntvldefaults to 150 (75 seconds).

/* 两次KeepAlive探测间的时间间隔 */

  1. The tcp_keepcnt option specifies the maximum number of keepalive probes tobe sent. The value of TCP_KEEPCNT is an integer value between 1 and n, where n s the value of the systemwide tcp_keepcnt parameter.

/* 判定断开前的KeepAlive探测次数*/

int keepIdle = 1000;
int keepInterval = 10;
int keepCount = 10;

Setsockopt(listenfd, SOL_TCP, TCP_KEEPIDLE, (void *)&keepIdle, sizeof(keepIdle));
Setsockopt(listenfd, SOL_TCP,TCP_KEEPINTVL, (void *)&keepInterval, sizeof(keepInterval));
Setsockopt(listenfd,SOL_TCP, TCP_KEEPCNT, (void *)&keepCount, sizeof(keepCount));

SO_KEEPALIVE​设置空闲2小时才发送一个“保持存活探测分节”,不能保证实时检测。对于判断网络断开时间太长,以及需要及时响应的程序不太适应。

当然也可以修改时间间隔参数,但是会影响到所有打开此选项的套接口!关联了完成端口的socket可能会忽略掉该套接字选项。

Socket编程

套接字(Socket)概念

Socket本身有“插座”的意思,在Linux环境下,用于表示进程间网络通信的特殊文件类型。本质为内核借助缓冲区形成的伪文件。

既然是文件,那么理所当然的,我们可以使用文件描述符引用套接字。与管道类似的,Linux系统将其封装成文件的目的是为了统一接口,使得读写套接字和读写文件的操作一致。区别是管道主要应用于本地进程间通信,而套接字多应用于网络进程间数据的传递。

在TCP/IP协议中,“IP地址+TCP或UDP端口号”唯一标识网络通讯中的一个进程。“IP地址+端口号”就对应一个socket。欲建立连接的两个进程各自有一个socket来标识,那么这两个socket组成的socket pair就唯一标识一个连接。因此可以用Socket来描述网络连接的一对一关系。

套接字通信原理如下图所示:

image

在网络通信中,套接字一定是成对出现的。 一端的发送缓冲区对应对端的接收缓冲区。我们使用同一个文件描述符索发送缓冲区和接收缓冲区。

TCP/IP协议最早在BSD UNIX上实现,为TCP/IP协议设计的应用层编程接口称为socket API。本章的主要内容是socket API,主要介绍TCP协议的函数接口,最后介绍UDP协议和UNIX Domain Socket的函数接口。

image

预备知识

网络字节序

内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分。网络数据流同样有大端小端之分。可是如何定义网络数据流的地址呢?发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址。

TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节。例如上一节的UDP段格式,地址0-1是16位的源端口号,如果这个端口号是1000(0x3e8),则地址0是0x03,地址1是0xe8,也就是先发0x03,再发0xe8,这16位在发送主机的缓冲区中也应该是低地址存0x03,高地址存0xe8。但是,如果发送主机是小端字节序的,这16位被解释成0xe803,而不是1000。因此,发送主机把1000填到发送缓冲区之前需要做字节序的转换。同样地,接收主机如果是小端字节序的,接到16位的源端口号也要做字节序的转换。如果主机是大端字节序的,发送和接收都不需要做转换。同理,32位的IP地址也要考虑网络字节序和主机字节序的问题。

为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换

#include <arpa/inet.h>

uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);

h表示host,n表示network,l表示32位长整数,s表示16位短整数。

如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回,如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。

IP地址转换函数

用于将 IP 地址从可读的点分十进制表示法(IPv4)或冒号分隔的十六进制表示法(IPv6)转换为网络字节序的二进制形式。

早期:

#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
int inet_aton(const char *cp, struct in_addr *inp);
in_addr_t inet_addr(const char *cp);
char *inet_ntoa(struct in_addr in);
  • 只能处理IPv4的ip地址

  • 不可重入函数(不可重入函数是指在多线程环境下,当函数在执行过程中被中断,再次进入该函数时,可能会导致程序出错的函数。)

  • 注意参数是struct in_addr

现在:

#include <arpa/inet.h>
int inet_pton(int af, const char *src, void *dst);
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
  • inet_pton​参数:

    • af​:地址族,通常为 AF_INET​(IPv4)或 AF_INET6​(IPv6)。
    • src​:指向包含 IP 地址字符串的指针。
    • dst​:指向存储转换后的二进制形式 IP 地址的缓冲区。
  • inet_pton​返回值:

    • 1​:成功转换。
    • 0​:src​ 不是有效的 IP 地址字符串。
    • -1​:af​ 不支持,或者出现其他错误。
  • inet_ntop​参数:

    • af​:地址族,通常为 AF_INET​(IPv4)或 AF_INET6​(IPv6)。
    • src​:指向包含二进制形式 IP 地址的缓冲区。
    • dst​:指向存储转换后的字符串形式 IP 地址的缓冲区。
    • size​:dst​ 缓冲区的大小,以字节为单位。
  • inet_ntop​返回值:

    • 成功:返回 dst​,即转换后的字符串地址。
    • 失败:返回 NULL​,并设置 errno​ 以指示错误。
  • 支持IPv4和IPv6

  • 可重入函数(函数重入是指一个函数在执行过程中可以被再次调用,而不会导致程序出错。)

  • inet_pton和inet_ntop不仅可以转换IPv4的in_addr,还可以转换IPv6的in6_addr。

sockaddr数据结构

strcut sockaddr 很多网络编程函数诞生早于IPv4协议,那时候都使用的是sockaddr结构体,为了向前兼容,现在sockaddr退化成了(void *)的作用,传递一个地址给函数,至于这个函数是sockaddr_in还是sockaddr_in6,由地址族确定,然后函数内部再强制类型转化为所需的地址类型。man 7 ip查看详细信息。

struct sockaddr {
	sa_family_t sa_family; 		/* address family, AF_xxx */
	char sa_data[14];			/* 14 bytes of protocol address */
};
struct sockaddr_in {
	__kernel_sa_family_t sin_family; 			/* Address family */  	地址结构类型
	__be16 sin_port;					 		/* Port number */		端口号
	struct in_addr sin_addr;					/* Internet address */	IP地址
	/* Pad to size of `struct sockaddr'. */
	unsigned char __pad[__SOCK_SIZE__ - sizeof(short int) -
	sizeof(unsigned short int) - sizeof(struct in_addr)];
};
struct in_addr {						/* Internet address. */
	__be32 s_addr;
};
struct sockaddr_in6 {
	unsigned short int sin6_family; 		/* AF_INET6 */
	__be16 sin6_port; 					/* Transport layer port # */
	__be32 sin6_flowinfo; 				/* IPv6 flow information */
	struct in6_addr sin6_addr;			/* IPv6 address */
	__u32 sin6_scope_id; 				/* scope id (new in RFC2553) */
};
struct in6_addr {
	union {
		__u8 u6_addr8[16];
		__be16 u6_addr16[8];
		__be32 u6_addr32[4];
	} in6_u;
	#define s6_addr 		in6_u.u6_addr8
	#define s6_addr16 		in6_u.u6_addr16
	#define s6_addr32	 	in6_u.u6_addr32
};
#define UNIX_PATH_MAX 108
struct sockaddr_un {
	__kernel_sa_family_t sun_family; 	/* AF_UNIX */
	char sun_path[UNIX_PATH_MAX]; 	/* pathname */
};

Pv4和IP v6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位端口号和32位IP地址,IPv6地址用sockaddr_in6结构体表示,包括16位端口号、128位IP地址和一些控制字段。UNIX Domain Socket的地址格式定义在sys/un.h中,用sockaddr_un结构体表示。

各种socket地址结构体的开头都是相同的,前16位表示整个结构体的长度,后16位表示地址类型(并不是所有UNIX的实现都有长度字段,如Linux就没有)。IPv4、IPv6和Unix Domain Socket的地址类型分别定义为常数AF_INET、AF_INET6、AF_UNIX。这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容。

因此,socket API可以接受各种类型的sockaddr结构体指针做参数,例如bind、accept、connect等函数,这些函数的参数应该设计成void *类型以便接受各种类型的指针,但是sock API的实现早于ANSI C标准化,那时还没有void *类型,因此这些函数的参数都用struct sockaddr *类型表示,在传递参数之前要强制类型转换一下,例如:

struct sockaddr_in servaddr;
bind(listen_fd, (struct sockaddr *)&servaddr, sizeof(servaddr));		/* initialize servaddr */

网络套接字函数

socket模型创建流程图

socket函数

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
domain:
	AF_INET 这是大多数用来产生socket的协议,使用TCP或UDP来传输,用IPv4的地址
	AF_INET6 与上面类似,不过是来用IPv6的地址
	AF_UNIX 本地协议,使用在Unix和Linux系统上,一般都是当客户端和服务器在同一台及其上的时候使用
type:
	SOCK_STREAM 这个协议是按照顺序的、可靠的、数据完整的基于字节流的连接。这是一个使用最多的socket类型,这个socket是使用TCP来进行传输。
	SOCK_DGRAM 这个协议是无连接的、固定长度的传输调用。该协议是不可靠的,使用UDP来进行它的连接。
	SOCK_SEQPACKET该协议是双线路的、可靠的连接,发送固定长度的数据包进行传输。必须把这个包完整的接受才能进行读取。
	SOCK_RAW socket类型提供单一的网络访问,这个socket类型使用ICMP公共协议。(ping、traceroute使用该协议)
	SOCK_RDM 这个类型是很少使用的,在大部分的操作系统上没有实现,它是提供给数据链路层使用,不保证数据包的顺序
protocol:
	传0 表示使用默认协议。
返回值:
	成功:返回指向新创建的socket的文件描述符,失败:返回-1,设置errno

socket()打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符,应用程序可以像读写文件一样用read/write在网络上收发数据,如果socket()调用出错则返回-1。对于IPv4,domain参数指定为AF_INET。对于TCP协议,type参数指定为SOCK_STREAM,表示面向流的传输协议。如果是UDP协议,则type参数指定为SOCK_DGRAM,表示面向数据报的传输协议。protocol参数的介绍从略,指定为0即可。

bind函数

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd:
	socket文件描述符
addr:
	构造出IP地址加端口号
addrlen:
	sizeof(addr)长度
返回值:
	成功返回0,失败返回-1, 设置errno

服务器程序所监听的网络地址和端口号通常是固定不变的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接,因此服务器需要调用bind绑定一个固定的网络地址和端口号。

bind()的作用是将参数sockfd和addr绑定在一起,使sockfd这个用于网络通讯的文件描述符监听addr所描述的地址和端口号。前面讲过,struct sockaddr *是一个通用指针类型,addr参数实际上可以接受多种协议的sockaddr结构体,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度。如:

struct sockaddr_in servaddr;
bzero(&servaddr, sizeof(servaddr));  // 清零结构体
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(6666);

首先将整个结构体清零,然后设置地址类型为AF_INET,网络地址为INADDR_ANY,这个宏表示本地的任意IP地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个IP地址,这样设置可以在所有的IP地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个IP地址,端口号为6666。

listen函数

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int listen(int sockfd, int backlog);
sockfd:
	socket文件描述符
backlog:
	排队建立3次握手队列和刚刚建立3次握手队列的链接数和

查看系统默认backlog

cat /proc/sys/net/ipv4/tcp_max_syn_backlog
  • 典型的服务器程序可以同时服务于多个客户端,当有客户端发起连接时,服务器调用的accept()返回并接受这个连接,如果有大量的客户端发起连接而服务器来不及处理,尚未accept的客户端就处于连接等待状态,listen()声明sockfd处于监听状态,并且最多允许有backlog个客户端处于连接等待状态,如果接收到更多的连接请求就忽略。listen()成功返回0,失败返回-1。

accept函数

#include <sys/types.h> 		/* See NOTES */
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
sockdf:
	socket文件描述符
addr:
	传出参数,返回链接客户端地址信息,含IP地址和端口号
addrlen:
	传入传出参数(值-结果),传入sizeof(addr)大小,函数返回时返回真正接收到地址结构体的大小
返回值:
	成功返回一个新的socket文件描述符,用于和客户端通信,失败返回-1,设置errno

三次握手完成后,服务器调用accept()接受连接,如果服务器调用accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来。addr是一个传出参数,accept()返回时传出客户端的地址和端口号。addrlen参数是一个传入传出参数(value-result argument),传入的是调用者提供的缓冲区addr的长度以避免缓冲区溢出问题,传出的是客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)。如果给addr参数传NULL,表示不关心客户端的地址。

服务器程序结构是这样的:

// 1.创建好服务端socket,2.绑定地址和端口号,3.设置好监听等待数,4.下面的循环accept客户端socket的链接
while (1) {
	cliaddr_len = sizeof(cliaddr);
	connfd = accept(listenfd, (struct sockaddr *)&cliaddr, &cliaddr_len);
	n = read(connfd, buf, MAXLINE);
	......
	close(connfd);
}

整个是一个while死循环,每次循环处理一个客户端连接。由于cliaddr_len是传入传出参数,每次调用accept()之前应该重新赋初值。accept()的参数listenfd是先前的监听文件描述符,而accept()的返回值是另外一个文件描述符connfd,之后与客户端之间就通过这个connfd通讯,最后关闭connfd断开连接,而不关闭listenfd,再次回到循环开头listenfd仍然用作accept的参数。accept()成功返回一个文件描述符(该文件描述符是真正与客户端socket成对进行通信的端点),出错返回-1。

connect函数

#include <sys/types.h> 					/* See NOTES */
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockdf:
	socket文件描述符
addr:
	传入参数,指定服务器端地址信息,含IP地址和端口号
addrlen:
	传入参数,传入sizeof(addr)大小
返回值:
	成功返回0,失败返回-1,设置errno

客户端需要调用connect()连接服务器,connect和bind的参数形式一致,区别在于bind的参数是自己的地址,而connect的参数是对方的地址。connect()成功返回0,出错返回-1。

socket编程中read函数

ssize_t read(int fd, void *buf, size_t count)
  • fd:对端的文件描述符

  • buf:缓冲区数组

  • count:buf的大小

  • 返回值

    • >0​:表示成功读取的字节数,且数据已读取到缓冲区buf中。
    • 0​:表示对端已经关闭连接。
    • -1​:表示错误

阻塞套接字read​ 会阻塞,直到有数据可读或发生错误。

非阻塞套接字:如果没有数据可读,read​ 会返回 -1​,并将 errno​ 设置为 EAGAIN​ 或 EWOULDBLOCK

C/S模型-TCP

下图是基于TCP协议的客户端/服务器程序的一般流程:

服务器调用socket()、bind()、listen()完成初始化后,调用accept()阻塞等待,处于监听端口的状态,客户端调用socket()初始化后,调用connect()发出SYN段并阻塞等待服务器应答,服务器应答一个SYN-ACK段,客户端收到后从connect()返回,同时应答一个ACK段,服务器收到后从accept()返回。

数据传输的过程:

  • 建立连接后,TCP协议提供全双工的通信服务,但是一般的客户端/服务器程序的流程是由客户端主动发起请求,服务器被动处理请求,一问一答的方式。因此,服务器从accept()返回后立刻调用read(),读socket就像读管道一样,如果没有数据到达就阻塞等待,这时客户端调用write()发送请求给服务器,服务器收到后从read()返回,对客户端的请求进行处理,在此期间客户端调用read()阻塞等待服务器的应答,服务器调用write()将处理结果发回给客户端,再次调用read()阻塞等待下一条请求,客户端收到后从read()返回,发送下一条请求,如此循环下去。

如果客户端没有更多的请求了,就调用close()关闭连接,就像写端关闭的管道一样,服务器的read()返回0,这样服务器就知道客户端关闭了连接,也调用close()关闭连接。注意,任何一方调用close()后,连接的两个传输方向都关闭,不能再发送数据了。如果一方调用shutdown()则连接处于半关闭状态,仍可接收对方发来的数据。

在学习socket API时要注意应用程序和TCP协议层是如何交互的:应用程序调用某个socket函数时TCP协议层完成什么动作,比如调用connect()会发出SYN段应用程序如何知道TCP协议层的状态变化,比如从某个阻塞的socket函数返回就表明TCP协议收到了某些段,再比如read()返回0就表明收到了FIN段。

server实例

以下是一个使用linux socket实现的server最小实现,只能够接受一个客户端连接,并且将客户端发来的字符串转为大写后回写回去。

#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<errno.h>
#include<pthread.h>
#include<ctype.h>
#include<sys/socket.h>
#include<arpa/inet.h>

define SERV_PORT 9527

void sys_err(const char* str)
{
perror(str);
exit(1);
}

int main(int argc, char* argv[])
{
int lfd = 0, cfd = 0;
int size;
char buf[BUFSIZ], client_IP[1024]; // 根据系统,BUFSIZ=4096或者8192
struct sockaddr_in serv_addr, client_addr;
socklen_t client_addr_len = sizeof(client_addr);
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(SERV_PORT);
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd==-1)
{
sys_err("socket error");
}

bind(lfd, (struct sockaddr*)&amp;serv_addr, sizeof(serv_addr));
listen(lfd, 128);
cfd = accept(lfd, (struct sockaddr*)&amp;client_addr, &amp;client_addr_len);
if(cfd==-1)
{
    sys_err(&quot;accept error&quot;);
}

printf(&quot;client ip:%s port:%d\n&quot;, inet_ntop(AF_INET, &amp;client_addr.sin_addr.s_addr,
client_IP, sizeof(client_IP)), ntohs(client_addr.sin_port));

while (1)
{
    size = read(cfd, buf, BUFSIZ);
    write(STDOUT_FILENO, buf, size);
    for(int i=0; i&lt;size; ++i)
    {
        buf[i]=toupper(buf[i]);
    }

    write(cfd, buf, size);
}

close(lfd);
close(cfd);

return 0;

}

nc(Netcat)命令

nc <目标主机> <目标端口>
  • nc​ 是一个功能强大的网络工具,可以用来读写网络连接和文件。使用实例:
nc 127.0.0.1 9527
  • 链接到本地ip的9527端口

可以使用nc命令来模拟socket客户端连接到socket服务端。快捷验证服务器是否能正常响应。

client实例

以下是一个使用linux socket实现的client最小实现,会连接本地ip的9527端口发送“hello world”,并且将server传输过来的数据打印到终端。

#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<ctype.h>
#include<arpa/inet.h>
#include<sys/socket.h>
#include<unistd.h>
#include<errno.h>
#include<pthread.h>

define SERV_PORT 9527

void sys_err(const char* str)
{
perror(str);
exit(1);
}

int main(int argc, char* argv[])
{
int cfd=0, ret=0;

char buf[BUFSIZ];
struct sockaddr_in serv_addr;
serv_addr.sin_family = AF_INET;
serv_addr.sin_port = htons(9527);
inet_pton(AF_INET, &quot;127.0.0.1&quot;, &amp;serv_addr.sin_addr.s_addr);
// serv_addr.sin_addr.s_addr = ;

cfd = socket(AF_INET, SOCK_STREAM, 0);
ret = connect(cfd, (struct sockaddr*)&amp;serv_addr, sizeof(serv_addr));
if(ret == -1)
{
    sys_err(&quot;connect error&quot;);
}
write(cfd, &quot;hello world&quot;, 12);
int size = read(cfd, buf, sizeof(buf));
write(STDOUT_FILENO, buf, size);
write(STDOUT_FILENO, &quot;\n&quot;, 2);

close(cfd);
return 0;

}

出错处理封装函数

我们知道,系统调用不能保证每次都成功,必须进行出错处理,这样一方面可以保证程序逻辑正常,另一方面可以迅速得到故障信息。为使错误处理的代码不影响主程序的可读性,我们把与socket相关的一些系统函数加上错误处理代码包装成新的函数,做成一个模块wrap.c​。

wrap.c

#include "wrap.h"
void perr_exit(const char *s)
{
	perror(s);
	exit(1);
}
int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr)
{
	int n;
	again:
	if ( (n = accept(fd, sa, salenptr)) < 0) {
		if ((errno == ECONNABORTED) || (errno == EINTR))
			goto again;
		else
			perr_exit("accept error");
	}
	return n;
}
int Bind(int fd, const struct sockaddr *sa, socklen_t salen)
{
	int n;
	if ((n = bind(fd, sa, salen)) < 0)
		perr_exit("bind error");
	return n;
}
int Connect(int fd, const struct sockaddr *sa, socklen_t salen)
{
	int n;
	if ((n = connect(fd, sa, salen)) < 0)
		perr_exit("connect error");
	return n;
}
int Listen(int fd, int backlog)
{
	int n;
	if ((n = listen(fd, backlog)) < 0)
		perr_exit("listen error");
	return n;
}
int Socket(int family, int type, int protocol)
{
	int n;
	if ( (n = socket(family, type, protocol)) < 0)
		perr_exit("socket error");
	return n;
}
ssize_t Read(int fd, void *ptr, size_t nbytes)
{
	ssize_t n;
again:
	if ( (n = read(fd, ptr, nbytes)) == -1) {
		if (errno == EINTR)
			goto again;
		else
			return -1;
	}
	return n;
}
ssize_t Write(int fd, const void *ptr, size_t nbytes)
{
	ssize_t n;
again:
	if ( (n = write(fd, ptr, nbytes)) == -1) {
		if (errno == EINTR)
			goto again;
		else
			return -1;
	}
	return n;
}
/*
    当 write 被中断信号(如 SIGINT 或 SIGTERM)打断时,errno 会被设置为 EINTR。
    在这种情况下,write 并没有真正失败,只是被信号中断了。
    为了确保写操作能够完成,代码通过 goto again 重新调用 write。
*/

int Close(int fd)
{
int n;
if ((n = close(fd)) == -1)
perr_exit("close error");
return n;
}
ssize_t Readn(int fd, void *vptr, size_t n)
{
size_t nleft;
ssize_t nread;
char *ptr;

ptr = vptr;
nleft = n;

while (nleft &gt; 0) {
	if ( (nread = read(fd, ptr, nleft)) &lt; 0) {
		if (errno == EINTR)
			nread = 0;
		else
			return -1;
	} else if (nread == 0)
		break;
	nleft -= nread;
	ptr += nread;
}
return n - nleft;

}

ssize_t Writen(int fd, const void *vptr, size_t n)
{
size_t nleft;
ssize_t nwritten;
const char *ptr;

ptr = vptr;
nleft = n;

while (nleft &gt; 0) {
	if ( (nwritten = write(fd, ptr, nleft)) &lt;= 0) {
		if (nwritten &lt; 0 &amp;&amp; errno == EINTR)
			nwritten = 0;
		else
			return -1;
	}
	nleft -= nwritten;
	ptr += nwritten;
}
return n;

}

static ssize_t my_read(int fd, char *ptr)
{
static int read_cnt;
static char *read_ptr;
static char read_buf[100];

if (read_cnt &lt;= 0) {

again:
if ((read_cnt = read(fd, read_buf, sizeof(read_buf))) < 0) {
if (errno == EINTR)
goto again;
return -1;
} else if (read_cnt == 0)
return 0;
read_ptr = read_buf;
}
read_cnt--;
*ptr = *read_ptr++;
return 1;
}

ssize_t Readline(int fd, void *vptr, size_t maxlen)
{
ssize_t n, rc;
char c, *ptr;
ptr = vptr;

for (n = 1; n &lt; maxlen; n++) {
	if ( (rc = my_read(fd, &amp;c)) == 1) {
		*ptr++ = c;
		if (c == '\n')
			break;
	} else if (rc == 0) {
		*ptr = 0;
		return n - 1;
	} else
		return -1;
}
*ptr = 0;
return n;

}

wrap.h

#ifndef __WRAP_H_
#define __WRAP_H_
#include <stdlib.h>
#include <errno.h>
#include <stdio.h>
#include <unistd.h>
#include <sys/socket.h>
void perr_exit(const char *s);
int Accept(int fd, struct sockaddr *sa, socklen_t *salenptr);
int Bind(int fd, const struct sockaddr *sa, socklen_t salen);
int Connect(int fd, const struct sockaddr *sa, socklen_t salen);
int Listen(int fd, int backlog);
int Socket(int family, int type, int protocol);
ssize_t Read(int fd, void *ptr, size_t nbytes);
ssize_t Write(int fd, const void *ptr, size_t nbytes);
int Close(int fd);
ssize_t Readn(int fd, void *vptr, size_t n);
ssize_t Writen(int fd, const void *vptr, size_t n);
ssize_t Readline(int fd, void *vptr, size_t maxlen);
#endif

高并发服务器

多进程并发服务器

server.c

/* server.c */
#include <stdio.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <unistd.h>
#include <ctype.h>
#include "wrap.h"

define MAXLINE 80

define SERV_PORT 8000

void do_sigchild(int num)
{
while(waitpid(0, NULL, WNOHANG) > 0);
}

int main()
{
struct sockaddr_in servaddr, cliaddr;
socklen_t cliaddr_len;
int listenfd, connfd;
char buf[MAXLINE];
char str[INET_ADDRSTRLEN];
int i, n;
pid_t pid;

struct sigaction newact;
newact.sa_handler = do_sigchild;
sigemptyset(&amp;newact.sa_mask);
newact.sa_flags = 0;
sigaction(SIGCHLD, &amp;newact, NULL);

listenfd = Socket(AF_INET, SOCK_STREAM, 0);

bzero(&amp;servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);

Bind(listenfd, (struct sockaddr*)&amp;servaddr, sizeof(servaddr));
Listen(listenfd, 20);
printf(&quot;Accepting connects ...\n&quot;);
while(1)
{
    cliaddr_len = sizeof(cliaddr);
    connfd = Accept(listenfd, (struct sockaddr*)&amp;cliaddr, &amp;cliaddr_len);
    pid = fork();
    if(pid==0)
    {
        Close(listenfd);
        while(1)
        {
            n = Read(connfd, buf, MAXLINE);
            if(n==0)
            {
                printf(&quot;the other side has been closed.\n&quot;);
                break;
            }
            printf(&quot;received from %s at PORT %d\n&quot;,
                    inet_ntop(AF_INET, &amp;cliaddr.sin_addr, str, sizeof(str)),
                    ntohs(cliaddr.sin_port));
            for(i=0; i&lt;n; i++)
                buf[i] = toupper(buf[i]);
            Write(connfd, buf, n);
        }
        Close(connfd);
        return 0;
    }else if (pid&gt;0)
    {
        Close(connfd);
    }else
    {
        perr_exit(&quot;fork&quot;);
    }
    
}
Close(listenfd);
return 0;

}

  • 总共分为三部分:

    • 注册子进程回收函数。
    • 在Accept之前,创建Socket,绑定地址,设置监听数。
    • 在while循环内Accept连接的客户端,并且接收到连接的客户端后,就fork一个子进程,在子进程内循环处理和客户端的通信。

client.c

#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include "wrap.h"

define MAXLINE 80

define SERV_PORT 8000

int main(int argc, char* argv[])
{
struct sockaddr_in servaddr;
char buf[MAXLINE];
int sockfd, n=0;

sockfd = Socket(AF_INET, SOCK_STREAM, 0);

bzero(&amp;servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
inet_pton(AF_INET, &quot;127.0.0.1&quot;, &amp;servaddr.sin_addr);
servaddr.sin_port = htons(SERV_PORT);

Connect(sockfd, (struct sockaddr*)&amp;servaddr, sizeof(servaddr));
while(fgets(buf, MAXLINE, stdin) != NULL)
{
    Write(sockfd, buf, strlen(buf));  // 将终端输入写到服务端
    n = Read(sockfd, buf, MAXLINE);  // 读取服务端返回的数据
    if(n==0)
    {
        printf(&quot;the other side has been closed.\n&quot;);
        break;
    }else
    {
        Write(STDOUT_FILENO, buf, n);
    }
}
Close(sockfd);
return 0;

}

  • 实现逻辑:

    • 创建socket,使用该socket连接到服务端。
    • 循环读取终端输入,若是输入不为空,就将输入的数据通过socket写到服务端。
  • 注意点:stdin​ 和 STDIN_FILENO​ 都表示标准输入流,通常指向终端输入。

    • stdin

      • 定义stdin​ 是一个文件指针(FILE *​),定义在 <stdio.h>​ 中。
      • 用途:它是 C 标准库中的高级接口,用于文件 I/O 操作(如 fread​、fwrite​、fgets​ 等)。
      • 底层实现stdin​ 实际上是基于文件描述符的封装,底层仍然使用操作系统提供的文件描述符。
    • STDIN_FILENO

      • 定义STDIN_FILENO​ 是一个整数常量,表示标准输入的文件描述符,定义在 <unistd.h>​ 中。
      • 用途:它是操作系统级别的低级接口,用于直接操作文件描述符(如 read​、write​ 等系统调用)。
      • STDIN_FILENO​ 的值通常是 0​,表示标准输入的文件描述符。

多线程并发服务器

在使用线程模型开发服务器时需考虑以下问题:

  1. 调整进程内最大文件描述符上限
  2. 线程如有共享数据,考虑线程同步
  3. 服务于客户端线程退出时,退出处理。(退出值,分离态)
  4. 系统负载,随着链接客户端增加,导致其它线程不能及时得到CPU

server.c

#include <stdio.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h> 
#include <ctype.h>

include "wrap.h"

define MAXLINE 80

define SERV_PORT 8000

struct s_info
{
struct sockaddr_in cliaddr;
int connfd;
};

// 线程函数
void* do_work(void* arg)
{
int n,i;
struct s_info* ts = (struct s_info*)arg;
char buf[MAXLINE];
char str[INET_ADDRSTRLEN];
pthread_detach(pthread_self()); // 设置线程分离
while(1)
{
n = Read(ts->connfd, buf, MAXLINE);
if(n==0)
{
printf("the other side has been closed.\n");
break;
}
printf("received from %s at PORT %d\n",
inet_ntop(AF_INET, &(ts->cliaddr.sin_addr), str, sizeof(str)),
ntohs(ts->cliaddr.sin_port));
for(i=0; i<n; ++i)
{
buf[i] = toupper(buf[i]);
}
Write(ts->connfd, buf, n);
}
Close(ts->connfd);

return 0;

}

int main()
{
struct sockaddr_in servaddr, cliaddr;
socklen_t cliaddr_len;
int listenfd, connfd;
int i=0;
pthread_t tid;
struct s_info ts[256]; // 最多存储256个客户端信息
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
bzero(&servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
inet_pton(AF_INET, "127.0.0.1", &servaddr.sin_addr);
servaddr.sin_port = htons(SERV_PORT);

Bind(listenfd, (struct sockaddr*)&amp;servaddr, sizeof(servaddr));
Listen(listenfd, 20);

printf(&quot;Accepting connections ...\n&quot;);
while(1)
{
    cliaddr_len = sizeof(cliaddr);
    connfd = Accept(listenfd, (struct sockaddr*)&amp;cliaddr, &amp;cliaddr_len);
    ts[i].connfd = connfd;
    ts[i].cliaddr = cliaddr;
    pthread_create(&amp;tid, NULL, do_work, &amp;ts[i]); // pthread_create在达到最大线程数时会出错处理,增加了服务器稳定性
    i++;
}
return 0;

}

  • 使用一个结构体存储于客户端连接的socket描述符和客户端的地址信息结构体。
  • 线程函数使用客户端信息结构体作为参数,在内部处理数据传输行为。
  • 主程序中,先是创建监听socket,绑定ip地址和端口,设置监听数,然后再while循环内循环Accept对端的连接,然后启动子线程进行处理,每个线程服务于一个客户端。

多路I/O转接服务器

多路IO转接服务器也叫做多任务IO服务器。该类服务器实现的主旨思想是,不再由应用程序自己监视客户端连接,取而代之由内核替应用程序监视文件。

主要使用的方法有三种:

  • select
  • poll
  • epoll(最常使用)

select

  1. select能监听的文件描述符个数受限于FD_SETSIZE,一般为1024,单纯改变进程打开的文件描述符个数并不能改变select监听文件个数。
  2. 解决1024以下客户端时使用select是很合适的,但如果链接客户端过多,select采用的是轮询模型,会大大降低服务器响应效率,不应在select上投入更多精力。
#include <sys/select.h>
/* According to earlier standards */
#include <sys/time.h>
#include <sys/types.h>
#include <unistd.h>
int select(int nfds, fd_set *readfds, fd_set *writefds,
			fd_set *exceptfds, struct timeval *timeout);
nfds: 		监控的文件描述符集里最大文件描述符加1,因为此参数会告诉内核检测前多少个文件描述符的状态
readfds:	监控有读数据到达文件描述符集合,传入传出参数
writefds:	监控写数据到达文件描述符集合,传入传出参数
exceptfds:	监控异常发生达文件描述符集合,如带外数据到达异常,传入传出参数
timeout:	定时阻塞监控时间,3种情况
			1.NULL,永远等下去
			2.设置timeval,等待固定时间
			3.设置timeval里时间均为0,检查描述字后立即返回,轮询
return:		产生事件的文件描述符总个数,包括所有事件的文件描述符
struct timeval {
	long tv_sec; /* seconds */
	long tv_usec; /* microseconds */
};
void FD_CLR(int fd, fd_set *set); 	//把文件描述符集合里fd清0
int FD_ISSET(int fd, fd_set *set); 	//测试文件描述符集合里fd是否置1
void FD_SET(int fd, fd_set *set); 	//把文件描述符集合里fd位置1
void FD_ZERO(fd_set *set); 			//把文件描述符集合里所有位清0

server.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <ctype.h>
#include "wrap.h"
#include <sys/select.h>

define MAXLINE 80

define SERV_PORT 8000

int main(int argc, char* argv[])
{
// 初始化各种变量
int i, maxi, maxfd, listenfd, connfd, sockfd;
int nready, client[__FD_SETSIZE];
ssize_t n;
fd_set rset, allset;
char buf[MAXLINE];
char str[INET_ADDRSTRLEN];
socklen_t cliaddr_len;
struct sockaddr_in cliaddr, servaddr;
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);

// 创建监听socket
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
// 绑定地址
Bind(listenfd, (struct sockaddr*)&amp;servaddr, sizeof(servaddr));
// 设置监听数
Listen(listenfd, 20);

maxfd = listenfd;
maxi = -1;

for(i=0; i&lt;__FD_SETSIZE; ++i)
{
    client[i] = -1;   // 将client数组元素的初始值初始化为-1
}

FD_ZERO(&amp;allset);
FD_SET(listenfd, &amp;allset);

while(1)
{
    rset = allset;
    nready = select(maxfd+1, &amp;rset, NULL, NULL, NULL);

    if(nready&lt;0)
        perr_exit(&quot;select error&quot;);

    // listenfd有事件,处理连接的客户端
    if(FD_ISSET(listenfd, &amp;rset))  
    {
        cliaddr_len = sizeof(cliaddr);
        connfd = Accept(listenfd, (struct sockaddr*)&amp;cliaddr, &amp;cliaddr_len);
        printf(&quot;received from %s at PORT %d\n&quot;,
                inet_ntop(AF_INET, &amp;cliaddr.sin_addr, str, sizeof(str)),
                ntohs(cliaddr.sin_port));
        // 将连接客户端的文件描述符保存到client数组
        for(i=0; i&lt;__FD_SETSIZE; ++i)
        {
            if(client[i]&lt;0)
            {
                client[i] = connfd;
                break;
            }
        }

        if(i==__FD_SETSIZE)
        {
            fputs(&quot;too many clients\n&quot;, stderr);
            exit(1);
        }

        FD_SET(connfd, &amp;allset);  // 将连接客户端的文件描述符加入事件集
        if(connfd&gt;maxfd)  // 更新最大文件描述符
            maxfd = connfd;
        if(i&gt;maxi)  // 更新client数组中存在connfd的最大索引
            maxi = i;
        if(--nready == 0)  // 说明只有listenfd有事件,那就进入下一轮循环
            continue;
    }
    // 循环处理所有connfd
    for(i=0; i&lt;=maxi; ++i)
    {
        if((sockfd = client[i]) &lt; 0)  // 说明该位置没有存储connfd,进入下一轮循环
        {
            continue;
        }
        if(FD_ISSET(sockfd, &amp;rset))  // 该文件描述符在监听事件集中,说明有事件
        {
            if((n = Read(sockfd, buf, MAXLINE)) == 0)
            {
                Close(sockfd);
                FD_CLR(sockfd, &amp;allset);
                client[i] = -1;
            }else
            {
                int j;
                for(j=0; j&lt;n; ++j)
                {
                    buf[j] = toupper(buf[j]);
                }
                Write(sockfd, buf, n);  
            }
            if(--nready == 0)  // 如果nready=1,则说明只有一个文件描述符有事件,因此处理完就直接break
                break;
        }
    }
}

Close(listenfd);
return 0;

}

  • 逻辑分析

    • 在经过正常的创建listenfd,绑定ip和端口号,设置监听数后,首先是将listenfd加入集合,然后使用select监听集合中的读事件(服务端的读事件对应于客户端的写事件)。
    • 当select第一次返回时,必然是监听到了listenfd有读事件,也就意味着有客户端连接了,因此会使用accept返回与客户端连接的文件描述符,并将该文件描述符加入监听事件集,接着进入下一次循环,并在下一轮循环中阻塞在nready = select(maxfd+1, &rset, NULL, NULL, NULL);​等待有读事件产生。
    • 直到select再次监听到读事件,首先判断listenfd是否有事件产生,如果产生,就执行一系列操作将新的文件描述符加入监听集合,然后处理连接客户端的文件描述符的事件。如果listenfd没有事件产生,那就直接去处理连接客户端的文件描述符的事件。

epoll

epoll是Linux下多路复用IO接口select/poll的增强版本,它能显著提高程序在大量并发连接中只有少量活跃的情况下的系统CPU利用率,因为它会复用文件描述符集合来传递结果而不用迫使开发者每次等待事件之前都必须重新准备要被侦听的文件描述符集合,另一点原因就是获取事件的时候,它无须遍历整个被侦听的描述符集,只要遍历那些被内核IO事件异步唤醒而加入Ready队列的描述符集合就行了。

目前epoll是linux大规模并发网络程序中的热门首选模型

epoll除了提供select/poll那种IO事件的电平触发(Level Triggered)外,还提供了边沿触发(Edge Triggered),这就使得用户空间程序有可能缓存IO状态,减少epoll_wait/epoll_pwait的调用,提高应用程序效率。

可以使用cat命令查看一个进程可以打开的socket描述符上限。

cat /proc/sys/fs/file-max

如有需要,可以通过修改配置文件的方式修改该上限值。

sudo vi /etc/security/limits.conf

在文件尾部写入以下配置,soft软限制,hard硬限制。

* soft nofile 65536
* hard nofile 100000

但是在Ubuntu18.04版本及以后,上述方法不能生效,详情参考Ubuntu18.04修改文件描述符限制不生效及解决方法1

基础API

  1. 创建一个epoll句柄。
#include <sys/epoll.h>
int epoll_create(int size)		
  • size:监听的文件描述符数目。epoll_create​ 的 size​ 参数从 Linux 2.6.8 开始被忽略,内核会动态调整所需的数据结构大小,不再需要这个提示。尽管如此,size​ 参数仍然需要大于零,以确保向后兼容性。当新的 epoll​ 应用程序在旧内核上运行时,内核会依赖这个参数。

  • 返回值:

    • 成功,返回一个非负的文件描述符,用于标识这个epoll实例。该文件描述符会占用系统中的一个文件描述符资源。
    • 失败,返回-1,设置errno。
  1. 维护文件描述符集合。
#include <sys/epoll.h>
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event)
struct epoll_event {
			__uint32_t events; /* Epoll events */
			epoll_data_t data; /* User data variable */
};
typedef union epoll_data {
			void *ptr;
			int fd;
			uint32_t u32;
			uint64_t u64;
} epoll_data_t;
/* Epoll events */
EPOLLIN :	表示对应的文件描述符可以读(包括对端SOCKET正常关闭)
EPOLLOUT:	表示对应的文件描述符可以写
EPOLLPRI:	表示对应的文件描述符有紧急的数据可读(这里应该表示有带外数据到来)
EPOLLERR:	表示对应的文件描述符发生错误
EPOLLHUP:	表示对应的文件描述符被挂断;
EPOLLET: 	将EPOLL设为边缘触发(Edge Triggered)模式,这是相对于水平触发(Level Triggered)而言的
EPOLLONESHOT:只监听一次事件,当监听完这次事件之后,如果还需要继续监听这个socket的话,需要再次把这个socket加入到EPOLL队列里
  • epfd:epoll_create返回的句柄

  • op:表示动作选项,用3个宏来表示:

    • EPOLL_CTL_ADD (注册新的fd到epfd),

    • EPOLL_CTL_MOD (修改已经注册的fd的监听事件),

    • EPOLL_CTL_DEL (从epfd删除一个fd);

  • fd:文件描述符

  • event:告诉内核监听的事件,一个结构体,携带两个信息:

    • 一是事件类别
    • 二是文件描述符(或是能唯一标识该文件描述符的信息)。
  1. 等待所监控文件描述符上有事件产生。
#include <sys/epoll.h>
int epoll_wait(int epfd, struct epoll_event *events, 
				int maxevents, int timeout)
  • epfd:epoll_create返回的句柄

  • events:传出参数,存储内核得到的事件的集合

  • maxevents: 告诉内核这个events有多大,这个maxevents的值不能大于创建epoll_create()时的size。epoll_create​ 的 size​ 参数从 Linux 2.6.8 开始被忽略,因此从该版本以后,maxevents仅用于限制events数组的大小,而不需要考虑size​参数。

  • timeout:超时时间

    • -1​:阻塞
    • 0​:立即返回,非阻塞
    • >0​:阻塞指定毫秒
  • 返回值:

    • 成功,返回有多少文件描述符就绪
    • 时间到,返回0
    • 出错,返回-1

server.c

#include <stdlib.h>
#include <string.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h>
#include <errno.h>
#include <ctype.h>
#include "wrap.h"

define MAXLINE 80

define SERV_PORT 8000

define OPEN_MAX 1024

struct client_info
{
char str[INET_ADDRSTRLEN];
u_int16_t port;
};

int main(int argc, char* grgv[])
{
int i, j, listenfd, connfd, sockfd;
int nready, efd, res;
ssize_t n;
char buf[MAXLINE], str[INET_ADDRSTRLEN];
socklen_t cliaddr_len;
struct client_info client[OPEN_MAX];
struct sockaddr_in cliaddr, servaddr;
struct epoll_event tep, ep[OPEN_MAX];

// 创建socket
listenfd = Socket(AF_INET, SOCK_STREAM, 0);
printf(&quot;listenfd : %d\n&quot;, listenfd);

bzero(&amp;servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);

// 绑定ip和端口
Bind(listenfd, (struct sockaddr*)&amp;servaddr, sizeof(servaddr));
// 设置最大连接队列长度
Listen(listenfd, 20);

// 创建epoll句柄
efd = epoll_create(1);
printf(&quot;efd : %d\n&quot;, efd);
if(efd == -1)
    perr_exit(&quot;epoll_create error&quot;);

tep.events = EPOLLIN;
tep.data.fd = listenfd;

// 将listenfd加入监听
res = epoll_ctl(efd, EPOLL_CTL_ADD, listenfd, &amp;tep);
if(res == -1)
{
    perr_exit(&quot;epoll_ctl error&quot;);
}

while(1)
{
    nready = epoll_wait(efd, ep, OPEN_MAX, -1);  // 阻塞等待事件产生
    if(nready == -1)
        perr_exit(&quot;epoll_wait error&quot;);
    
    // 处理有事件产生的文件描述符
    for(i=0; i&lt;nready; ++i)
    {
        if(!(ep[i].events &amp; EPOLLIN))
            continue;
        if(ep[i].data.fd == listenfd)  // 处理listenfd
        {
            cliaddr_len = sizeof(cliaddr);
            connfd = Accept(listenfd, (struct sockaddr*)&amp;cliaddr, &amp;cliaddr_len);
            printf(&quot;received from %s at PORT %d\n&quot;, 
					inet_ntop(AF_INET, &amp;cliaddr.sin_addr, str, sizeof(str)), 
					ntohs(cliaddr.sin_port));
            printf(&quot;connfd : %d\n&quot;, connfd);

            if(connfd-5 &gt;= OPEN_MAX)  // 判断连接的客户端数是否超过了上限,connfd-5是因为listenfd和efd分别占了文件描述符中的3和4,因此连接客户端的文件描述符从5开始
                perr_exit(&quot;too many clients connected&quot;);
            // 连接的客户端未超过上限,将客户端信息加入client数组,同时将文件描述符添加到epoll监听树中
            strcpy(client[connfd-5].str, inet_ntop(AF_INET, &amp;cliaddr.sin_addr, str, sizeof(str)));
            client[connfd-5].port = ntohs(cliaddr.sin_port);
            tep.events = EPOLLIN;
            tep.data.fd = connfd;
            res = epoll_ctl(efd, EPOLL_CTL_ADD, connfd, &amp;tep);
            if(res == -1)
                perr_exit(&quot;epoll_ctl error&quot;);
        }else
        {  // 处理连接客户端的文件描述符
            sockfd = ep[i].data.fd;
            n = Read(sockfd, buf, MAXLINE);
            if(n == 0)
            {
                res = epoll_ctl(efd, EPOLL_CTL_DEL, sockfd, NULL);  // 将文件描述符移出句柄
                if(res == -1)
                    perr_exit(&quot;epoll_ctl error&quot;);
                
                Close(sockfd);
                printf(&quot;client %s : %d closed connection\n&quot;, client[sockfd-5].str,
                        client[sockfd-5].port);
                bzero(&amp;client[sockfd-5], sizeof(struct client_info));
            }else
            {
                for(j=0; j&lt;n; ++j)
                    buf[j] = toupper(buf[j]);
                Write(sockfd, buf, n);
            }
        }
    }
}
Close(listenfd);
Close(efd);
return 0;

}

  • 整体流程

    • 创建监听socket,返回的文件描述符为listenfd
    • 绑定listenfd到相应的ip和端口,设置最大连接队列长度
    • 创建epoll,返回efd标识该epoll实例
    • 将listenfd加入监听,且设置监听事件为读事件
    • epoll_wait阻塞并等待监听的文件描述符产生事件
    • 事件产生,获取事件数组,遍历,判断事件类别是否是读事件和文件描述符是否是listenfd。如果是文件描述符是listenfd,执行accept接收连接的客户端,并把对应的文件描述符加入epoll监听;否则说明该事件是客户端有数据传来,执行相应的操作。
    • 最后,关闭listenfd和efd。
  • 注意点:

    • 使用结构体数组client存储连接的客户端的ip和端口信息,因为希望连接的最大客户端数量为1024,而listenfd和efd占了3、4两个文件描述符,所以连接客户端的文件描述符connfd必然从5开始,最大为1028,因此使用client[connfd-5]来存储connfd对应的客户端ip和端口。(当然,如果文件描述符数量要超过1024,必须要更改文件描述符上限)

epoll进阶

事件模型

EPOLL事件有两种模型:

  • Edge Triggered(ET),边缘触发:只有数据到来才触发,不管缓冲区中是否有数据
  • Level Triggered(LT),水平触发:只要缓冲区有数据,就会触发

假设有以下步骤:

  1. 将一个用来从管道中读取数据的文件描述符(rfd)添加到epoll中。
  2. 管道的另一端写入了2kB数据。
  3. 调用epoll_wait,它会返回rfd和该文件描述符产生的读事件。
  4. 从rfd读取1kB的数据。
  5. 调用epoll_wait...

在上述过程中,有可以有两种工作模式:

  • ET模式:

    • 如果在第一步添加文件描述符到epoll中时使用了EPOLLET标志,那么在第5步调用epoll_wait之后将可能挂起,因为剩余的数据还存在与文件的输入缓冲区内,而数据发出端还在等待一个针对已发出数据的反馈信息。只有在监视的文件句柄上发生了某个事件的时候,ET模式才会汇报事件。epoll工作在ET模式下,必须使用非阻塞I/O,以避免由于一个文件句柄的阻塞导致处理多个文件描述符的任务饿死。 最好以下面的方式调用ET模式epoll接口:

      • 基于非阻塞I/O(这里指的是加入epoll的文件描述符是非阻塞的,并非epoll_wait为非阻塞)。原因是阻塞IO无法通过错误码判断缓冲区中是否还有数据,而非阻塞IO可以。
      • 在 ET 模式下,应用程序必须一次性处理完所有数据,否则可能会错过后续的数据。例如,如果一个文件描述符在 ET 模式下触发了读事件,应用程序需要循环读取数据,直到读取失败(返回 -1​ 且错误码为 EAGAIN​ 或 EWOULDBLOCK​)
  • LT模式:

    • LT(level triggered):LT是缺省的工作方式,并且同时支持block和no-block socket。在这种做法中,内核告诉你一个文件描述符是否就绪了,然后你可以对这个就绪的fd进行IO操作。如果你不作任何操作,内核还是会继续通知你的,所以,这种模式编程出错误可能性要小一点。传统的select/poll都是这种模型的代表。
    • ET(edge-triggered):ET是高速工作方式,只支持no-block socket。在这种模式下,当描述符从未就绪变为就绪时,内核通过epoll告诉你。然后它会假设你知道文件描述符已经就绪,并且不会再为那个文件描述符发送更多的就绪通知。请注意,如果一直不对这个fd做IO操作(从而导致它再次变成未就绪),内核不会发送更多的通知。

ET模式和LT模式的优缺点:

水平触发(LT)模式

  • 优点

    • 编程简单:LT 模式下,只要文件描述符上有数据可读或可写,就会一直触发事件,直到数据被处理完毕。
    • 数据完整性高:不用担心漏掉事件,即使没有一次性读取所有数据,后续仍然会继续触发事件。
    • 适合小数据量:对于数据量较小的场景,LT 模式可以简化逻辑。
  • 缺点

    • 性能开销大:在高并发场景下,LT 模式会频繁触发事件,导致大量不必要的系统调用和上下文切换。
    • 资源浪费:即使数据已经准备好,但未被完全处理,LT 模式仍会不断触发事件,可能导致资源浪费。

边缘触发(ET)模式

  • 优点

    • 效率高:ET 模式只在文件描述符的状态发生变化时触发事件,减少了不必要的通知,适合高并发场景。
    • 减少系统调用:通过减少事件触发次数,ET 模式可以显著降低系统调用的开销,提高程序性能。
    • 适合大数据量:对于读取大型文件或处理大量数据的场景,ET 模式可以一次性处理所有数据。
  • 缺点

    • 编程复杂:需要在事件触发后一次性处理完所有数据,否则可能会漏掉后续的数据。
    • 数据丢失风险:如果在处理事件时没有读取完所有数据,且后续没有再次触发事件,可能会导致数据丢失。
    • 多线程问题:在多线程环境中,ET 模式需要特别处理,例如使用 EPOLLONESHOT​ 来避免多个线程同时操作同一个文件描述符。

ET模式的三个例子

这三个例子仅是为了演示epoll在ET模式下的表现,省略了错误处理。

基于阻塞I/O的进程间pipe通信

子进程向管道中写入数据,每次写入十个字节,前五个字符为aaaa\n​,后五个字符就是bbbb\n​,以此类推,字符会不断递增。父进程以ET模式获取事件,从阻塞模式的I/O缓冲区中读取数据,每次读取五个字节。

#include <stdio.h>
#include <stdlib.h>
#include <sys/epoll.h>
#include <errno.h>
#include <unistd.h>

define MAXLINE 10

int main(int argc, char* argv[])
{
int efd, i;
int pfd[2];
pid_t pid;
char buf[MAXLINE], ch = 'a';

pipe(pfd);
pid = fork();
if(pid == 0)
{
    close(pfd[0]);
    while(1)
    {
        for(i=0; i&lt;MAXLINE/2; ++i)
        {
            buf[i] = ch; 
        }
        buf[i-1] = '\n';  // aaaa\n
        ch++;  // 'a'+1 = 'b'
        for(; i&lt;MAXLINE; ++i)
        {
            buf[i] = ch;
        }
        buf[i-1] = '\n';  // bbbb\n
        ch++;
        write(pfd[1], buf, MAXLINE);
        sleep(2);
    }
    close(pfd[1]);
}else if(pid&gt;0)
{
    struct epoll_event event, resevent[10];
    int res, len;
    close(pfd[1]);

    efd = epoll_create(1);
    // event.events = EPOLLIN;  // 默认水平触发
    event.events = EPOLLIN | EPOLLET;  // 边沿触发
    event.data.fd = pfd[0];
    epoll_ctl(efd, EPOLL_CTL_ADD, pfd[0], &amp;event);

    while(1)
    {
        res = epoll_wait(efd, resevent, 10, -1);  // 阻塞等待
        printf(&quot;res %d\n&quot;, res);
        if(resevent[0].data.fd == pfd[0])
        {
            len = read(pfd[0], buf, MAXLINE/2);
            write(STDOUT_FILENO, buf, len);
        }
    }

    close(pfd[0]);
    close(efd);
}else
{
    perror(&quot;fork&quot;);
    exit(-1);
}

return 0;

}

基于阻塞I/O的socket通信

客户端向socket中写入数据,每次写入十个字节,前五个字符为aaaa\n​,后五个字符就是bbbb\n​,以此类推,字符会不断递增。服务端以ET模式获取事件,从阻塞模式的I/O缓冲区中读取数据,每次读取五个字节。

现象

Accepting connections...
received from 127.0.0.1 at PORT 39562
res 1
aaaa
res 1
bbbb
server.c
#include <stdio.h>
#include <string.h>
#include <strings.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/epoll.h>
#include <unistd.h>

define MAXLINE 10

define SERV_PORT 9000

int main()
{
/创建相关变量/
// 创建服务端地址信息结构体变量和客户端地址信息结构体变量
struct sockaddr_in servaddr, cliaddr;
// 创建客户端地址信息结构体长度变量并初始化
socklen_t cliaddr_len = sizeof(cliaddr);
// 创建监听文件描述符,连接客户端的文件描述符
int listenfd, connfd;
// 创建缓冲区
char buf[MAXLINE];
// 创建存储ip地址本地格式的字符串的字符数组
char str[INET_ADDRSTRLEN];
// 创建eopll返回的文件描述符变量
int efd;

/*socket创建*/ 
listenfd = socket(AF_INET, SOCK_STREAM, 0);

// 初始化服务端地址信息结构体
bzero(&amp;servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);

/*绑定地址*/
bind(listenfd, (struct sockaddr*)&amp;servaddr, sizeof(servaddr));
/*设置监听队列*/
listen(listenfd, 20);

/*创建epoll_event结构体和相关变量*/
struct epoll_event event, resevents[10];
int res, len;
/*创建epoll*/
efd = epoll_create(10);
/*设置event*/
event.events = EPOLLIN | EPOLLET;
// event.events = EPOLLIN;

printf(&quot;Accepting connections...\n&quot;);

/*接收客户端连接*/
connfd = accept(listenfd, (struct sockaddr*)&amp;cliaddr, &amp;cliaddr_len);
printf(&quot;received from %s at PORT %d\n&quot;,
		inet_ntop(AF_INET, &amp;cliaddr.sin_addr, str, sizeof(str)),
		ntohs(cliaddr.sin_port));

/*继续设置event*/
event.data.fd = connfd;

/*将连接客户端的文件描述符添加到epoll中*/
epoll_ctl(efd, EPOLL_CTL_ADD, connfd, &amp;event);

/*处理客户端*/
while(1)
{
    /*接收产生的事件*/
    res = epoll_wait(efd, resevents, 10, -1);
    printf(&quot;res %d\n&quot;, res);
    if(resevents[0].data.fd == connfd)
    {
        len = read(connfd, buf, MAXLINE/2);
        write(STDOUT_FILENO, buf, len);
    }
}

close(listenfd);
close(efd);
close(connfd);

return 0;

}

client.c
#include <stdio.h>
#include <string.h>
#include <strings.h>
#include <unistd.h>
#include <netinet/in.h>

define MAXLINE 10

define SERV_PORT 9000

int main(int argc, char* argv[])
{
/创建相关变量/
struct sockaddr_in servaddr;
char buf[MAXLINE];
int sockfd, i;
char ch = 'a';

/*创建socket*/
sockfd = socket(AF_INET, SOCK_STREAM, 0);

/*初始化servaddr*/
bzero(&amp;servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);

/*连接服务端*/
connect(sockfd, (struct sockaddr*)&amp;servaddr, sizeof(servaddr));

/*处理与服务端的通信*/
while(1)
{
    for(i=0; i&lt;MAXLINE/2; ++i)
    {
        buf[i] = ch;
    }
    buf[i-1] = '\n';
    ch++;
    for(; i&lt;MAXLINE; ++i)
    {
        buf[i] = ch;
    }
    buf[i-1] = '\n';
    ch++;
    write(sockfd, buf, MAXLINE);
    sleep(5);
}
close(sockfd);

return 0;

}

基于非阻塞I/O的socket通信

客户端向socket中写入数据,每次写入十个字节,前五个字符为aaaa\n​,后五个字符就是bbbb\n​,以此类推,字符会不断递增。服务端以ET模式获取事件,从阻塞模式的I/O缓冲区中读取数据,每次读取五个字节。

现象

Accepting connections...
received from 127.0.0.1 at PORT 53708
res 1
aaaa
bbbb
res 1
cccc
dddd
server.c
#include <stdio.h>
#include <string.h>
#include <strings.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>
#include <sys/wait.h>
#include <sys/types.h>
#include <sys/epoll.h>
#include <unistd.h>
#include <fcntl.h>

define MAXLINE 10

define SERV_PORT 9000

int main()
{
/创建相关变量/
// 创建服务端地址信息结构体变量和客户端地址信息结构体变量
struct sockaddr_in servaddr, cliaddr;
// 创建客户端地址信息结构体长度变量并初始化
socklen_t cliaddr_len = sizeof(cliaddr);
// 创建监听文件描述符,连接客户端的文件描述符
int listenfd, connfd;
// 创建缓冲区
char buf[MAXLINE];
// 创建存储ip地址本地格式的字符串的字符数组
char str[INET_ADDRSTRLEN];
// 创建eopll返回的文件描述符变量
int efd;
// 创建文件描述符标志
int flag;

/*socket创建*/ 
listenfd = socket(AF_INET, SOCK_STREAM, 0);

// 初始化服务端地址信息结构体
bzero(&amp;servaddr, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(SERV_PORT);

/*绑定地址*/
bind(listenfd, (struct sockaddr*)&amp;servaddr, sizeof(servaddr));
/*设置监听队列*/
listen(listenfd, 20);

/*创建epoll_event结构体和相关变量*/
struct epoll_event event, resevents[10];
int res, len;
/*创建epoll*/
efd = epoll_create(10);
/*设置event*/
event.events = EPOLLIN | EPOLLET;
// event.events = EPOLLIN;

printf(&quot;Accepting connections...\n&quot;);

/*接收客户端连接*/
connfd = accept(listenfd, (struct sockaddr*)&amp;cliaddr, &amp;cliaddr_len);
printf(&quot;received from %s at PORT %d\n&quot;,
		inet_ntop(AF_INET, &amp;cliaddr.sin_addr, str, sizeof(str)),
		ntohs(cliaddr.sin_port));

/*设置connef为非阻塞*/
flag = fcntl(connfd, F_GETFL);
flag |= O_NONBLOCK;
fcntl(connfd, F_SETFL, flag);

/*继续设置event*/
event.data.fd = connfd;

/*将连接客户端的文件描述符添加到epoll中*/
epoll_ctl(efd, EPOLL_CTL_ADD, connfd, &amp;event);

/*处理客户端*/
while(1)
{
    /*接收产生的事件*/
    res = epoll_wait(efd, resevents, 10, -1);
    printf(&quot;res %d\n&quot;, res);
    if(resevents[0].data.fd == connfd)
    {
        while((len = read(connfd, buf, MAXLINE/2)) &gt; 0)
            write(STDOUT_FILENO, buf, len);
    }
}

close(listenfd);
close(efd);
close(connfd);

return 0;

}

客户端程序同上一节。

分析基于阻塞I/O和非阻塞I/O的通信差异

可以看到除了文件描述符被设置为非阻塞模式外,基于阻塞I/O和非阻塞I/O的socket通信服务端差异主要在从文件描述符缓冲区读取数据部分:

  • 阻塞I/O:

    len = read(connfd, buf, MAXLINE/2);
    	write(STDOUT_FILENO, buf, len);
    
  • 非阻塞I/O:

    while((len = read(connfd, buf, MAXLINE/2)) > 0)
    	write(STDOUT_FILENO, buf, len);
    
  • 可以看的出来,阻塞I/O的两行程序是在connfd的缓冲区读取MAXLINE/2​个字节的数据并将其打印到终端,只读取一次。而非阻塞I/O的两行程序是在connfd的缓冲区读取MAXLINE/2​个字节的数据并将其打印到终端,重复读取直到缓冲区数据为空

那么,为什么阻塞I/O读取数据的代码不能像非阻塞I/O这样写呢?

让我们改一下非阻塞I/O中读取数据部分的两行代码,使其与非阻塞I/O中的写法一致,现象如下:

Accepting connections...
received from 127.0.0.1 at PORT 60632
res 1
aaaa
bbbb
cccc
dddd
eeee
ffff
gggg
hhhh
iiii
jjjj
  • 看得出来,阻塞I/O将永远不会退出while循环,因为:

    • 阻塞I/O在缓冲区为空时,read函数会阻塞直到有数据到达
    • 而非阻塞I/O在缓冲区为空时,read函数会返回0,这样就可以退出while循环
  • 所以阻塞I/O的程序不能这么写。

ET模式由于只在文件描述符的状态发生变化时触发事件,使用阻塞I/O可能无法将数据一次全部读取,而ET模式又不会再继续通知,这就要求程序应该具备在一次事件中读取全部数据的能力,这毫无疑问必须使用非阻塞I/O。这就是为什么ET模式必须使用非阻塞I/O。

epoll反应堆模型

与之前写法最大的差异在于,epoll反应堆模型不仅监听文件描述符读事件,还监听文件描述符写事件。也就是说,通过内核确定文件描述符可读时我们再去读取,确定文件描述符可写时我们才去写入。(在此之前我们的所有例程都只监听了读事件,然后就将读取到的数据回写,而这样做实际上是有风险的,因为我们并不能确定对端文件描述符此时一定可写,万一写入缓冲区已满呢,因此监听写事件,让内核判断文件描述符是否可写,可写时我们再进行写入)

此外就是一些模块化的写法,并且使用一个结构体保存了一个文件描述符以及相关的所有数据,便于使用。尤其是结构体中的callback函数指针,对文件描述符需要调用的事件处理函数进行了封装,增加了程序的可读性。

/* 	 *epoll基于非阻塞I/O事件驱动 	 */  	
#include <stdio.h>  	
#include <sys/socket.h>  	
#include <sys/epoll.h>  	
#include <arpa/inet.h>  	
#include <fcntl.h>  	
#include <unistd.h>  
#include <errno.h>  
#include <string.h>  
#include <stdlib.h>  
#include <time.h>  

define MAX_EVENTS 1024 //监听上限数

define BUFLEN 4096

define SERV_PORT 8080

void recvdata(int fd, int events, void *arg);
void senddata(int fd, int events, void *arg);

/* 描述就绪文件描述符相关信息 */

struct myevent_s {
int fd; //要监听的文件描述符
int events; //对应的监听事件
void arg; //泛型参数
void (
call_back)(int fd, int events, void *arg); //回调函数
int status; //是否在监听:1->在红黑树上(监听), 0->不在(不监听)
char buf[BUFLEN];
int len;
long last_active; //记录每次加入红黑树 g_efd 的时间值
};

int g_efd; //全局变量, 保存epoll_create返回的文件描述符
struct myevent_s g_events[MAX_EVENTS+1]; //自定义结构体类型数组. +1-->listen fd

/将结构体 myevent_s 成员变量 初始化/

void eventset(struct myevent_s ev, int fd, void (call_back)(int, int, void *), void *arg)
{
ev->fd = fd;
ev->call_back = call_back;
ev->events = 0;
ev->arg = arg;
ev->status = 0;
memset(ev->buf, 0, sizeof(ev->buf));
ev->len = 0;
ev->last_active = time(NULL); //调用eventset函数的时间

return;  

}

/* 向 epoll监听的红黑树 添加一个 文件描述符 */

//eventadd(efd, EPOLLIN, &g_events[MAX_EVENTS]);
void eventadd(int efd, int events, struct myevent_s *ev)
{
struct epoll_event epv = {0, {0}};
int op;
epv.data.ptr = ev;
epv.events = ev->events = events; //EPOLLIN 或 EPOLLOUT

if (ev-&gt;status == 0) {                                          //已经在红黑树 g_efd 里  
    op = EPOLL_CTL_ADD;                 //将其加入红黑树 g_efd, 并将status置1  
    ev-&gt;status = 1;  
}  

if (epoll_ctl(efd, op, ev-&gt;fd, &amp;epv) &lt; 0)                       //实际添加/修改  
    printf(&quot;event add failed [fd=%d], events[%d]\n&quot;, ev-&gt;fd, events);  
else  
    printf(&quot;event add OK [fd=%d], op=%d, events[%0X]\n&quot;, ev-&gt;fd, op, events);  

return ;  

}

/* 从epoll 监听的 红黑树中删除一个 文件描述符*/

void eventdel(int efd, struct myevent_s *ev)
{
struct epoll_event epv = {0, {0}};

if (ev-&gt;status != 1)                                        //不在红黑树上  
    return ;  

//epv.data.ptr = ev;  
epv.data.ptr = NULL;  
ev-&gt;status = 0;                                             //修改状态  
epoll_ctl(efd, EPOLL_CTL_DEL, ev-&gt;fd, &amp;epv);                //从红黑树 efd 上将 ev-&gt;fd 摘除  

return ;  

}

/* 当有文件描述符就绪, epoll返回, 调用该函数 与客户端建立链接 */

void acceptconn(int lfd, int events, void *arg)
{
struct sockaddr_in cin;
socklen_t len = sizeof(cin);
int cfd, i;

if ((cfd = accept(lfd, (struct sockaddr *)&amp;cin, &amp;len)) == -1) {  
    if (errno != EAGAIN &amp;&amp; errno != EINTR) {  
        /* 暂时不做出错处理 */  
    }  
    printf(&quot;%s: accept, %s\n&quot;, __func__, strerror(errno));  
    return ;  
}  

do {  
    for (i = 0; i &lt; MAX_EVENTS; i++)                                //从全局数组g_events中找一个空闲元素  
        if (g_events[i].status == 0)                                //类似于select中找值为-1的元素  
            break;                                                  //跳出 for  

    if (i == MAX_EVENTS) {  
        printf(&quot;%s: max connect limit[%d]\n&quot;, __func__, MAX_EVENTS);  
        break;                                                      //跳出do while(0) 不执行后续代码  
    }  

    int flag = 0;  
    if ((flag = fcntl(cfd, F_SETFL, O_NONBLOCK)) &lt; 0) {             //将cfd也设置为非阻塞  
        printf(&quot;%s: fcntl nonblocking failed, %s\n&quot;, __func__, strerror(errno));  
        break;  
    }  

    /* 给cfd设置一个 myevent_s 结构体, 回调函数 设置为 recvdata */  
    eventset(&amp;g_events[i], cfd, recvdata, &amp;g_events[i]);     
    eventadd(g_efd, EPOLLIN, &amp;g_events[i]);                         //将cfd添加到红黑树g_efd中,监听读事件  

} while(0);  

printf(&quot;new connect [%s:%d][time:%ld], pos[%d]\n&quot;,   
        inet_ntoa(cin.sin_addr), ntohs(cin.sin_port), g_events[i].last_active, i);  
return ;  

}

void recvdata(int fd, int events, void *arg)
{
struct myevent_s *ev = (struct myevent_s )arg;
int len;
char
tmpbuf = malloc(sizeof(ev->buf));

len = recv(fd, ev-&gt;buf, sizeof(ev-&gt;buf), 0);            //读文件描述符, 数据存入myevent_s成员buf中  
strcpy(tmpbuf, ev-&gt;buf);
eventdel(g_efd, ev);        //将该节点从红黑树上摘除  

if (len &gt; 0) {  

    // ev-&gt;len = len;  
    // ev-&gt;buf[len] = '\0';                                
    printf(&quot;C[%d]:%s\n&quot;, fd, ev-&gt;buf);  

    eventset(ev, fd, senddata, ev);                     //设置该 fd 对应的回调函数为 senddata  
    ev-&gt;len = len;
    strcpy(ev-&gt;buf, tmpbuf);
    ev-&gt;buf[len] = '\0';                                //手动添加字符串结束标记  
    free(tmpbuf);
    eventadd(g_efd, EPOLLOUT, ev);                      //将fd加入红黑树g_efd中,监听其写事件  

} else if (len == 0) {  
    close(ev-&gt;fd);  
    /* ev-g_events 地址相减得到偏移元素位置 */  
    printf(&quot;[fd=%d] pos[%ld], closed\n&quot;, fd, ev-g_events);  
} else {  
    close(ev-&gt;fd);  
    printf(&quot;recv[fd=%d] error[%d]:%s\n&quot;, fd, errno, strerror(errno));  
}  

return;  

}

void senddata(int fd, int events, void *arg)
{
struct myevent_s *ev = (struct myevent_s *)arg;
int len;

len = send(fd, ev-&gt;buf, ev-&gt;len, 0);                    //直接将数据 回写给客户端。未作处理  

eventdel(g_efd, ev);                                //从红黑树g_efd中移除  

if (len &gt; 0) {  

    printf(&quot;send[fd=%d], [%d]%s\n&quot;, fd, len, ev-&gt;buf);  
    eventset(ev, fd, recvdata, ev);                     //将该fd的 回调函数改为 recvdata  
    eventadd(g_efd, EPOLLIN, ev);                       //从新添加到红黑树上, 设为监听读事件  

} else {  
    close(ev-&gt;fd);                                      //关闭链接  
    printf(&quot;send[fd=%d] error %s\n&quot;, fd, strerror(errno));  
}  

return ;  

}

/*创建 socket, 初始化lfd */

void initlistensocket(int efd, short port)
{
struct sockaddr_in sin;

int lfd = socket(AF_INET, SOCK_STREAM, 0);  
fcntl(lfd, F_SETFL, O_NONBLOCK);                                            //将socket设为非阻塞  

memset(&amp;sin, 0, sizeof(sin));                                               //bzero(&amp;sin, sizeof(sin))  
sin.sin_family = AF_INET;  
sin.sin_addr.s_addr = INADDR_ANY;  
sin.sin_port = htons(port);  

bind(lfd, (struct sockaddr *)&amp;sin, sizeof(sin));  

listen(lfd, 20);  

/* void eventset(struct myevent_s *ev, int fd, void (*call_back)(int, int, void *), void *arg);  */  
eventset(&amp;g_events[MAX_EVENTS], lfd, acceptconn, &amp;g_events[MAX_EVENTS]);  

/* void eventadd(int efd, int events, struct myevent_s *ev) */  
eventadd(efd, EPOLLIN, &amp;g_events[MAX_EVENTS]);  

return ;  

}

int main(int argc, char *argv[])
{
unsigned short port = SERV_PORT;

if (argc == 2)  
    port = atoi(argv[1]);                           //使用用户指定端口.如未指定,用默认端口  

g_efd = epoll_create(MAX_EVENTS+1);                 //创建红黑树,返回给全局 g_efd   
if (g_efd &lt;= 0)  
    printf(&quot;create efd in %s err %s\n&quot;, __func__, strerror(errno));  

initlistensocket(g_efd, port);                      //初始化监听socket  

struct epoll_event events[MAX_EVENTS+1];            //保存已经满足就绪事件的文件描述符数组   
printf(&quot;server running:port[%d]\n&quot;, port);  

int checkpos = 0, i;  
while (1) {  
    /* 超时验证,每次测试100个链接,不测试listenfd 当客户端60秒内没有和服务器通信,则关闭此客户端链接 */  

    long now = time(NULL);                          //当前时间  
    for (i = 0; i &lt; 100; i++, checkpos++) {         //一次循环检测100个。 使用checkpos控制检测对象  
        if (checkpos == MAX_EVENTS)  
            checkpos = 0;  
        if (g_events[checkpos].status != 1)         //不在红黑树 g_efd 上  
            continue;  

        long duration = now - g_events[checkpos].last_active;       //客户端不活跃的世间  

        if (duration &gt;= 60) {  
            close(g_events[checkpos].fd);                           //关闭与该客户端链接  
            printf(&quot;[fd=%d] timeout\n&quot;, g_events[checkpos].fd);  
            eventdel(g_efd, &amp;g_events[checkpos]);                   //将该客户端 从红黑树 g_efd移除  
        }  
    }  

    /*监听红黑树g_efd, 将满足的事件的文件描述符加至events数组中, 1秒没有事件满足, 返回 0*/  
    int nfd = epoll_wait(g_efd, events, MAX_EVENTS+1, 1000);  
    if (nfd &lt; 0) {  
        printf(&quot;epoll_wait error, exit\n&quot;);  
        break;  
    }  

    for (i = 0; i &lt; nfd; i++) {  
        /*使用自定义结构体myevent_s类型指针, 接收 联合体data的void *ptr成员*/  
        struct myevent_s *ev = (struct myevent_s *)events[i].data.ptr;    

        if ((events[i].events &amp; EPOLLIN) &amp;&amp; (ev-&gt;events &amp; EPOLLIN)) {           //读就绪事件  
            ev-&gt;call_back(ev-&gt;fd, events[i].events, ev-&gt;arg);  
            //lfd  EPOLLIN    
        }  
        if ((events[i].events &amp; EPOLLOUT) &amp;&amp; (ev-&gt;events &amp; EPOLLOUT)) {         //写就绪事件  
            ev-&gt;call_back(ev-&gt;fd, events[i].events, ev-&gt;arg);  
        }  
    }  
}  

/* 退出前释放所有资源 */  
return 0;  

}

  • Tips

    • 在 Linux 中,^@​ 是一种常见的表示空字节(\0​)的方式,特别是在终端或文本编辑器中。这种表示法通常出现在十六进制转储工具(如 xxd)或某些文本编辑器中。

UDP服务器

UDP介绍

传输层主要应用的协议有两种,一种是TCP,另一种是UDP。TCP协议在网络通信中占主导地位,绝大多数的网络通信借助TCP协议完成数据传输,在上面的内容中,我们的服务器都是基于TCP协议完成的。但UDP同样也是网络通信中不可或缺的通信手段。

相较于TCP,UDP通信更像是发短信,不需要在数据传输之间建立连接。省去了三次握手的过程,通信速度大大提高,但同时通信的稳定性和准确率便得不到保证。因此,UDP被称为“无连接的不可靠报文传递”。

  • UDP的优缺点

    • 优点

      • 开销较小
      • 数据传输速度快
      • 实时性较强
    • 缺点

      • 数据传输不可靠,传输数据的正确率、传输顺序和流量都得不到控制和保证

因此,UDP多用于实时性要求较高的通信场合,如视频会议、电话会议等。并且,由于UDP协议的传输不可靠,为保证数据正确性,需要在应用层添加辅助校验协议来弥补UDP的不足。

与TCP类似,UDP也会出现缓冲区被填满的情况,TCP使用滑动窗口解决缓冲区满丢包的问题。UDP没有滑动窗口机制,通常采用如下两种方法解决:

  • 服务器应用层设计流量控制,控制发送数据速度

  • 借助setsockopt​函数改变接收缓冲区大小,例如:

    • #include <sys/socket.h>
      int setsockopt(int sockfd, int level, int optname, 
      			const void *optval, socklen_t optlen);
      int n = 220x1024
      setsockopt(sockfd, SOL_SOCKET, SO_RCVBUF, &n, sizeof(n));
      

C/S模型-UDP

image

由于UDP不需要维护连接,程序逻辑简单了很多,但是UDP协议是不可靠的,保证通讯可靠性的机制需要在应用层实现。

UDP程序实例

需要注意的是,TCP可以使用write/read、send/recv、sendto/recvfrom和sendmsg/recvmsg,而UDP只能使用sendto/recvfrom和sendmsg/recvmsg。

man手册描述如下:

  • send,sendto和sendmsg用于向另一个套接字传递消息。send仅仅用于连接套接字,而sendto 和 sendmsg 可用于任何情况下。

server.c

#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <errno.h>
#include <ctype.h>
#include <arpa/inet.h>
#include <unistd.h>

define PORT 9000

int main()
{
int sockfd, ret;
char buf[BUFSIZ];
char clientIP[INET_ADDRSTRLEN];
struct sockaddr_in servaddr, cliaddr;
socklen_t cliaddr_len;

sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if(sockfd == -1)
    perror(&quot;socket error!&quot;);

memset(buf, 0, sizeof(buf));
memset(&amp;servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(PORT);

ret = bind(sockfd, (struct sockaddr*)&amp;servaddr, sizeof(servaddr));
if(ret == -1)
    perror(&quot;bind error!&quot;);
cliaddr_len = sizeof(cliaddr);

while(1)
{
    ret = recvfrom(sockfd, buf, sizeof(buf), 0, (struct sockaddr*)&amp;cliaddr, &amp;cliaddr_len);  // 这里cliaddr是传出参数,获取发送消息的客户端的地址信息
    if(ret == -1)
        perror(&quot;recvfrom error!&quot;);
    printf(&quot;client %s : %d\n&quot;, inet_ntop(AF_INET, &amp;cliaddr.sin_addr.s_addr,
    clientIP, sizeof(clientIP)), ntohs(cliaddr.sin_port));
    for(int i=0; i&lt;ret; ++i)
    {
        buf[i] = toupper(buf[i]);
    }
    sendto(sockfd, buf, ret, 0, (struct sockaddr*)&amp;cliaddr, cliaddr_len);  // 这里cliaddr是传入参数,用于指定数据发送的地址
}

close(sockfd);

return 0;

}

client.c

#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <errno.h>
#include <ctype.h>
#include <arpa/inet.h>
#include <unistd.h>

define SERVIP "127.0.0.1"

define PORT 9000

int main()
{
int sockfd, ret;
char buf[BUFSIZ];
socklen_t servaddr_len;
struct sockaddr_in servaddr;

sockfd = socket(AF_INET, SOCK_DGRAM, 0);  // UDP客户端的sockfd可以不绑定地址和端口号,该情况下sendto时会自动绑定默认地址和一个随机端口号
if(sockfd == -1)
    perror(&quot;socket error!&quot;);

inet_pton(AF_INET, SERVIP, &amp;servaddr.sin_addr.s_addr);
servaddr.sin_port = htons(PORT);
servaddr.sin_family = AF_INET;
servaddr_len = sizeof(servaddr);

while(1)
{
    ret = read(STDIN_FILENO, buf, sizeof(buf));
    if(ret == -1)
        perror(&quot;read terminal error!&quot;);

    ret = sendto(sockfd, buf, ret, 0, (struct sockaddr*)&amp;servaddr, servaddr_len);
    if(ret == -1)
        perror(&quot;sendto error!&quot;);

    ret = recvfrom(sockfd, buf, sizeof(buf), 0, NULL, 0);
    if(ret == -1)
        perror(&quot;recvfrom error!&quot;);

    ret = write(STDOUT_FILENO, buf, ret);
    if(ret == -1)
        perror(&quot;write terminal error!&quot;);
}

close(sockfd);

return 0;

}

本地套接字

IPC:pipe, fifo, mmap, 信号, 本地套接字。

socket API原本是为网络通讯设计的,但后来在socket的框架上发展出一种IPC机制,就是UNIX Domain Socket。虽然网络socket也可用于同一台主机的进程间通讯(通过loopback地址127.0.0.1),但是UNIX Domain Socket用于IPC更有效率:不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。这是因为,IPC机制本质上是可靠的通讯,而网络协议是为不可靠的通讯设计的。UNIX Domain Socket也提供面向流和面向数据包两种API接口,类似于TCP和UDP,但是面向消息的UNIX Domain Socket也是可靠的,消息既不会丢失也不会顺序错乱。

UNIX Domain Socket是全双工的,API接口语义丰富,相比其它IPC机制有明显的优越性,目前已成为使用最广泛的IPC机制,比如X Window服务器和GUI程序之间就是通过UNIX Domain Socket通讯的。

对比网络套接字和本地套接字

流程差异

网络套接字流程:
  • server

    • lfd = socket(AF_INET, SOCK_STREAM, 0);
      

      struct sockaddr_in serv_addr;
      memset(&serv_addr, 0, sizeof(serv_addr));
      serv_addr.sin_family = AF_INET;
      serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);
      serv_addr.sin_port = htons(8000);

      bind(lfd, (struct sockaddr)&serv_addr, sizeof(servaddr));
      listen(lfd, 128);
      cfd = accept(lfd, (struct sockaddr
      )&clie_addr, &clie_len);

  • client

    • cfd = socket(AF_INET, SOCK_STREAM, 0);
      

      struct sockaddr_in serv_addr;
      memset(&serv_addr, 0, sizeof(serv_addr));
      serv_addr.sin_family = AF_INET;
      inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);
      serv_addr.sin_port = htons(SERV_PORT);

      connect(cfd , (struct sockaddr*)&serv_addr, sizeof(serv_addr));

本地套接字流程
  • server

    • lfd = socket(AF_UNIX, SOCK_STREAM, 0);
      

      struct sockaddr_un s_addr;
      memset(&s_addr, 0, sizeof(s_addr));
      s_addr.sun_family = AF_UNIX;
      strcpy(s_addr.sun_path, SERV_PATH);
      len = offsetof(struct sockaddr_un, sun_path) + strlen(s_addr.sun_path);
      unlink(SERV_PATH);

      bind(lfd, (struct sockaddr)&s_addr, (socklen_t)len);
      listen(lfd, 20);
      cfd = accept(lfd, (struct sockaddr
      )&c_addr, (socklen_t*)&len);

  • client

    • cfd = socket(AF_UNIX, SOCK_STREAM, 0);
      

      struct sockaddr_un s_addr, c_addr;
      memset(&c_addr, 0, sizeof(c_addr));
      memset(&s_addr, 0, sizeof(s_addr));
      c_addr.sun_family = AF_UNIX;
      s_addr.sun_family = AF_UNIX;
      strcpy(c_addr.sun_path, CLIE_PATH);
      strcpy(s_addr.sun_path, SERV_PATH);
      unlink(CLIE_PATH);

      bind(cfd, (struct sockaddr)&c_addr, (socklen_t)c_len);
      s_len = offsetof(struct sockaddr_un, sun_path) + strlen(s_addr.sun_path);
      connect(cfd, (struct sockaddr
      )&s_addr, (socklen_t)s_len);

注意点

  1. UNIX Domain Socket与网络socket编程最明显的不同在于地址结构不同:

    • 网络套接字地址结构用结构体struct sockaddr_in​表示,成员包括IP和端口号

    • 本地套接字地址结构用结构体struct sockaddr_un​表示,成员包括文件路径

    • 详细差异见如下所示:

      • struct sockaddr_in {
        	__kernel_sa_family_t sin_family; 			/* Address family */  	地址结构类型
        	__be16 sin_port;					 		/* Port number */		端口号
        	struct in_addr sin_addr;					/* Internet address */	IP地址
        };
        struct sockaddr_un {
        	__kernel_sa_family_t sun_family; 		/* AF_UNIX */			地址结构类型
        	char sun_path[UNIX_PATH_MAX]; 			/* pathname */		socket文件名(含路径)
        };
        
  2. 本地socket不支持隐式绑定,即使是客户端也必须明确绑定一个地址。网络socket客户端可以不绑定地址,而在连接服务端时会自动绑定地址。

  3. 本地socket会在bind函数调用时根据绑定的地址结构中的文件路径生成文件,若是该文件在此前已经生成,就会bind error。因此在bind前需要调用unlink(FILE_PATH);​来先删除文件,确保不出错。

  4. 本地套接字的地址结构长度要通过offsetof(struct sockaddr_un, sun_path)加上strlen(s_addr.sun_path)来得到。offsetof函数返回结构体struct sockaddr_un​中sun_path​的字节偏移(显然前面有两个字节的sun_family,所以返回2),strlen(s_addr.sun_path)​则是返回该文件名路径的实际长度。

server.c

#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <errno.h>
#include <sys/un.h>
#include <stddef.h>
#include <ctype.h>
#include <unistd.h>

define SERV_PATH "serv.socket"

define CLIE_PATH "clie.socket"

int main()
{
int lfd, cfd, len, size, ret;
struct sockaddr_un s_addr, c_addr;
char buf[BUFSIZ];

// 创建服务端监听socket
lfd = socket(AF_UNIX, SOCK_STREAM, 0);
if(lfd == -1)
    perror(&quot;socket error&quot;);

// 绑定地址,绑定的同时会创建socket文件
memset(&amp;s_addr, 0, sizeof(s_addr));
s_addr.sun_family = AF_UNIX;
strcpy(s_addr.sun_path, SERV_PATH);
len = offsetof(struct sockaddr_un, sun_path) + strlen(s_addr.sun_path);
unlink(SERV_PATH);  // 删除同名socket文件
ret = bind(lfd, (struct sockaddr*)&amp;s_addr, (socklen_t)len);
if(ret == -1)
    perror(&quot;bind error&quot;);

// 设置监听数
ret = listen(lfd, 20);
if(ret &lt; 0)
    perror(&quot;listen error&quot;);

printf(&quot;Accept ...\n&quot;);

while(1)
{
    len = sizeof(c_addr);
    // accept阻塞并等待客户端连接
    cfd = accept(lfd, (struct sockaddr*)&amp;c_addr, (socklen_t*)&amp;len);  // 该函数中的c_addr和len是传入传出参数
    
    len -= offsetof(struct sockaddr_un, sun_path);  // 求出客户端socket文件path长度
    c_addr.sun_path[len] = '\0';

    printf(&quot;client bind filename %s\n&quot;, c_addr.sun_path);

    // 读写数据
    while((size = read(cfd, buf, sizeof(buf))) &gt; 0)
    {
        for(int i=0; i&lt;size; ++i)
            buf[i] = toupper(buf[i]);
        write(cfd, buf, size);
        //memset(buf, 0, sizeof(buf));
    }
}

close(lfd);
close(cfd);

return 0;

}

client.c

#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <errno.h>
#include <sys/un.h>
#include <stddef.h>
#include <ctype.h>
#include <unistd.h>

define SERV_PATH "serv.socket"

define CLIE_PATH "clie.socket"

int main()
{
int cfd, c_len, s_len, size, ret;
char buf[BUFSIZ];
struct sockaddr_un c_addr, s_addr;
// 创建socket
cfd = socket(AF_UNIX, SOCK_STREAM, 0);
if(cfd == -1)
perror("socket error");

// 绑定地址
memset(&amp;c_addr, 0, sizeof(c_addr));
memset(&amp;s_addr, 0, sizeof(s_addr));
c_addr.sun_family = AF_UNIX;
s_addr.sun_family = AF_UNIX;
strcpy(c_addr.sun_path, CLIE_PATH);
strcpy(s_addr.sun_path, SERV_PATH);
unlink(CLIE_PATH);  // 删除同名socket文件
c_len = offsetof(struct sockaddr_un, sun_path) + strlen(c_addr.sun_path);
ret = bind(cfd, (struct sockaddr*)&amp;c_addr, (socklen_t)c_len);
if(ret == -1)
    perror(&quot;bind error&quot;);

// 连接服务端
s_len = offsetof(struct sockaddr_un, sun_path) + strlen(s_addr.sun_path);
ret = connect(cfd, (struct sockaddr*)&amp;s_addr, (socklen_t)s_len);
if(ret == -1)
    perror(&quot;connect error&quot;);

// 读写数据
while(1)
{
    size = read(STDIN_FILENO, buf, sizeof(buf));
    if(size&lt;0)
        perror(&quot;read terminal error&quot;);
    size = write(cfd, buf, size);
    if(size&lt;0)
        perror(&quot;write cfd error&quot;);
    size = read(cfd, buf, sizeof(buf));
    if(size&lt;0)
        perror(&quot;read cfd error&quot;);
    size = write(STDOUT_FILENO, buf, size);
    if(size&lt;0)
        perror(&quot;write terminal error&quot;);
    // memset(buf, 0, sizeof(buf));
}

close(cfd);

return 0;

}


  1. Ubuntu18.04修改文件描述符限制不生效及解决方法

    更改 Linux 系统进程可以打开的文件描述符数量限制是通过修改 /etc/security/limits.conf​ 来实现的,通过以下命令,打开配置文件。

    sudo vim /etc/security/limits.conf
    

    然后在打开的文件中添加并保存以下两行内容:

    * soft nofile 65535
    * hard nofile 65535
    

    最后,重启系统。通过 ulimit -n ​可以看到文件描述符数量限制。

    然而,我在 Ubuntu18.04 及以后的版本中使用上述步骤并没有生效,经过搜索可知,需要在 /etc/systemd/user.conf ​和 /etc/systemd/system.conf ​文件的末尾添加如下内容:

    DefaultLimitNOFILE=65535
    

    并且同样执行之前的修改 /etc/security/limits.conf​ 的操作,才能生效。

    不过,这种方法似乎对WSL不生效。

    参考:https://blog.csdn.net/u010312474/article/details/108666734

posted @ 2025-05-13 07:16  重光拾  阅读(54)  评论(0)    收藏  举报