套接字通信

 1.套接字socket

可以理解为就是网络通信

1.1概念

局域网和广域网

局域网:局域网将一定区域内的各种计算机、外部设备和数据库连接起来形成计算机通信的私有网络。 广域网:又称广域网、外网、公网。是连接不同地区局域网或城域网计算机通信的远程公共网络。 IP(Internet Protocol):本质是一个整形数,用于表示计算机在网络中的地址。IP 协议版本有两个:IPv4 和 IPv6

IPv4(Internet Protocol version4):

  • 使用一个 32 位的整形数描述一个 IP 地址,4 个字节,int 型

  • 也可以使用一个点分十进制字符串描述这个 IP 地址: 192.168.247.135

  • 分成了 4 份,每份 1 字节,8bit(char),最大值为 255

  • 0.0.0.0 是最小的 IP 地址

  • 255.255.255.255 是最大的 IP 地址

  • 按照 IPv4 协议计算,可以使用的 IP 地址共有 232 个

IPv6(Internet Protocol version6):

  • 使用一个 128 位的整形数描述一个 IP 地址,16 个字节

  • 也可以使用一个字符串描述这个 IP 地址:2001:0db8:3c4d:0015:0000:0000:1a2f:1a2b

  • 分成了 8 份,每份 2 字节,每一部分以 16 进制的方式表示

  • 按照 IPv6 协议计算,可以使用的 IP 地址共有 2128 个

  • 查看 IP 地址

 # linux
 $ ifconfig
 ​
 # windows
 $ ipconfig
 ​
 # 测试网络是否畅通
 # 主机a: 192.168.1.11
 # 当前主机: 192.168.1.12
 $ ping 192.168.1.11     # 测试是否可用连接局域网
 $ ping www.baidu.com    # 测试是否可用连接外网
 ​
 # 特殊的IP地址: 127.0.0.1  ==> 和本地的IP地址是等价的
 # 假设当前电脑没有联网, 就没有IP地址, 又要做网络测试, 可用使用 127.0.0.1 进行本地测试

端口

端口的作用是定位到主机上的某一个进程,通过这个端口进程就可以接受到对应的网络数据了。

端口也是一个整形数 unsigned short ,一个 16 位整形数,有效端口的取值范围是:0 ~ 65535(0 ~ 216-1)

提问:计算机中所有的进程都需要关联一个端口吗,一个端口可以被重复使用吗?

  • 不需要,如果这个进程不需要网络通信,那么这个进程就不需要绑定端口的

  • 一个端口只能给某一个进程使用,多个进程不能同时使用同一个端口

OSI/ISO 网络分层模型

OSI(Open System Interconnect),即开放式系统互联。 一般都叫 OSI 参考模型,是 ISO(国际标准化组织组织)在 1985 年研究的网络互联模型。

物理层

负责最后将信息编码成电流脉冲或其它信号用于网上传输

数据链路层

  • 数据链路层通过物理网络链路供数据传输。

  • 规定了 0 和 1 的分包形式,确定了网络数据包的形式;

网络层

  • 网络层负责在源和终点之间建立连接;

  • 此处需要确定计算机的位置,通过 IPv4,IPv6 格式的 IP 地址来找到对应的主机

传输层

  • 传输层向高层提供可靠的端到端的网络数据流服务。

  • 每一个应用程序都会在网卡注册一个端口号,该层就是端口与端口的通信

会话层

  • 会话层建立、管理和终止表示层与实体之间的通信会话;

  • 建立一个连接(自动的手机信息、自动的网络寻址);

表示层 对应用层数据编码和转化,确保以一个系统应用层发送的信息 可以被另一个系统应用层识别;

1.2网络协议

网络协议指的是计算机网络中互相通信的对等实体之间交换信息时所必须遵守的规则的集合。一般系统网络协议包括五个部分:通信环境,传输服务,词汇表,信息的编码格式,时序、规则和过程。先来通过下面几幅图了解一下常用的网络协议的格式:

TCP 协议 -> 传输层协议

UDP 协议 -> 传输层协议

IP 协议 -> 网络层协议

以太网帧协议 -> 网络接口层协议

数据的封装

在网络通信的时候,程序猿需要负责的应用层数据的处理 (最上层)

应用层的数据可以使用某些协议进行封装,也可以不封装

程序猿需要调用发送数据的接口函数,将数据发送出去

程序猿调用的 API 做底层数据处理

  • 传输层使用传输层协议打包数据

  • 网络层使用网络层协议打包数据

  • 网络接口层使用网络接口层协议打包数据

  • 数据被发送到 internet

  • 接收端接收到发送端的数据

程序猿调用接收数据的函数接收数据

  • 调用的 API 做相关的底层处理:

  • 网络接口层拆包 ==> 网络层的包

  • 网络层拆包 ==> 网络层的包

  • 传输层拆包 ==> 传输层数据

  • 如果应用层也使用了协议对数据进行了封装,数据的包的解析需要程序猿做

1.3 socket 编程

其目的是将 TCP/IP 协议相关软件移植到 UNIX 类系统中。设计者开发了一个接口,以便应用程序能简单地调用该接口通信。这个接口不断完善,最终形成了 Socket 套接字。Linux 系统采用了 Socket 套接字,因此,Socket 接口就被广泛使用,到现在已经成为事实上的标准。与套接字相关的函数被包含在头文件 sys/socket.h 中。

套接字对应程序猿来说就是一套网络通信的接口,使用这套接口就可以完成网络通信。网络通信的主体主要分为两部分:客户端和服务器端。在客户端和服务器通信的时候需要频繁提到三个概念:IP、端口、通信数据,下面介绍一下需要注意的一些细节问题。

1.3.1 字节序

在各种计算机体系结构中,对于字节、字等的存储机制有所不同,因而引发了计算机通信领域中一个很重要的问题,即通信双方交流的信息单元(比特、字节、字、双字等等)应该以什么样的顺序进行传送。如果不达成一致的规则,通信双方将无法进行正确的编 / 译码从而导致通信失败。

字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序,也就是说对于单字符来说是没有字节序问题的,字符串是单字符的集合,因此字符串也没有字节序问题。

目前在各种体系的计算机中通常采用的字节存储机制主要有两种:Big-Endian 和 Little-Endian,下面先从字节序说起。

Little-Endian -> 主机字节序 (小端)

  • 数据的低位字节存储到内存的低地址位 , 数据的高位字节存储到内存的高地址位

  • 我们使用的 PC 机,数据的存储默认使用的是小端

Big-Endian -> 网络字节序 (大端)

  • 数据的低位字节存储到内存的高地址位 , 数据的高位字节存储到内存的低地址位

  • 套接字通信过程中操作的数据都是大端存储的,包括:接收/发送的数据、IP地址、端口。

字节序举例

 // 有一个16进制的数, 有32位 (int): 0xab5c01ff
 // 字节序, 最小的单位: char 字节, int 有4个字节, 需要将其拆分为4份
 // 一个字节 unsigned char, 最大值是 255(十进制) ==> ff(16进制) 
                  内存低地址位                内存的高地址位
 --------------------------------------------------------------------------->
 小端:         0xff        0x01        0x5c        0xab
 大端:         0xab        0x5c        0x01        0xff

函数

BSD Socket 提供了封装好的转换接口,方便程序员使用。包括从主机字节序到网络字节序的转换函数:htons、htonl;从网络字节序到主机字节序的转换函数:ntohs、ntohl。

 #include <arpa/inet.h>
 // u:unsigned
 // 16: 16位, 32:32位
 // h: host, 主机字节序
 // n: net, 网络字节序
 // s: short
 // l: int
 ​
 // 这套api主要用于 网络通信过程中 IP 和 端口 的 转换
 // 将一个短整形从主机字节序 -> 网络字节序
 uint16_t htons(uint16_t hostshort); 
 // 将一个整形从主机字节序 -> 网络字节序
 uint32_t htonl(uint32_t hostlong);  
 ​
 // 将一个短整形从网络字节序 -> 主机字节序
 uint16_t ntohs(uint16_t netshort)
 // 将一个整形从网络字节序 -> 主机字节序
 uint32_t ntohl(uint32_t netlong);
1.3.2 IP 地址转换

虽然 IP 地址本质是一个整形数,但是在使用的过程中都是通过一个字符串来描述,下面的函数描述了如何将一个字符串类型的 IP 地址进行大小端转换:

 // 主机字节序的IP地址转换为网络字节序
 // 主机字节序的IP地址是字符串, 网络字节序IP地址是整形
 int inet_pton(int af, const char *src, void *dst); 

参数: af: 地址族 (IP 地址的家族包括 ipv4 和 ipv6) 协议 AF_INET: ipv4 格式的 ip 地址 AF_INET6: ipv6 格式的 ip 地址 src: 传入参数,对应要转换的点分十进制的 ip 地址: 192.168.1.100 dst: 传出参数,函数调用完成,转换得到的大端整形 IP 被写入到这块内存中 返回值:成功返回 1,失败返回 0 或者 - 1

1.3.3 sockaddr 数据结构
1.3.4 套接字函数

使用套接字通信函数需要包含头文件 <arpa/inet.h>,包含了这个头文件 <sys/socket.h> 就不用在包含了。

 // 创建一个套接字
 int socket(int domain, int type, int protocol);

参数: domain: 使用的地址族协议 AF_INET: 使用 IPv4 格式的 ip 地址 AF_INET6: 使用 IPv4 格式的 ip 地址 type: SOCK_STREAM: 使用流式的传输协议 SOCK_DGRAM: 使用报式 (报文) 的传输协议 protocol: 一般写 0 即可,使用默认的协议 SOCK_STREAM: 流式传输默认使用的是 tcp SOCK_DGRAM: 报式传输默认使用的 udp

返回值: 成功:可用于套接字通信的文件描述符 失败: -1 函数的返回值是一个文件描述符,通过这个文件描述符可以操作内核中的某一块内存,网络通信是基于这个文件描述符来完成的。

1.4 TCP 通信流程

TCP 是一个面向连接的,安全的,流式传输协议,这个协议是一个传输层协议

  • 面向连接:是一个双向连接,通过三次握手完成,断开连接需要通过四次挥手完成

  • 安全:tcp 通信过程中,会对发送的每一数据包都会进行校验,如果发现数据丢失,会自动重传

  • 流式传输:发送端和接收端处理数据的速度,数据的量都可以不一致

1.4.1 服务器端通信流程

1.创建用于监听的套接字,这个套接字是一个文件描述符

 int lfd = socket();

2.将得到的监听的文件描述符和本地的 IP 端口进行绑定

 bind();

3.设置监听 (成功之后开始监听,监听的是客户端的连接)

 listen();

4.等待并接受客户端的连接请求,建立新的连接,会得到一个新的文件描述符 (通信的),没有新连接请求就阻塞

 int cfd = accept();

5.通信,读写操作默认都是阻塞的

 // 接收数据
 read(); / recv();
 // 发送数据
 write(); / send();

6.断开连接,关闭套接字

 close();

在 tcp 的服务器端,有两类文件描述符

  • 监听的文件描述符

    • 只需要有一个

    • 不负责和客户端通信,负责检测客户端的连接请求,检测到之后调用 accept 就可以建立新的连接

  • 通信的文件描述符

    • 负责和建立连接的客户端通信

    • 如果有 N 个客户端和服务器建立了新的连接,通信的文件描述符就有 N 个,每个客户端和服务器都对应一个通信的文件描述符

  • 文件描述符对应的内存结构:

    • 一个文件文件描述符对应两块内存, 一块内存是读缓冲区, 一块内存是写缓冲区

    • 读数据: 通过文件描述符将内存中的数据读出, 这块内存称之为读缓冲区

    • 写数据: 通过文件描述符将数据写入到某块内存中, 这块内存称之为写缓冲区

  • 监听的文件描述符:

    • 客户端的连接请求会发送到服务器端监听的文件描述符的读缓冲区中

    • 读缓冲区中有数据,说明有新的客户端连接

    • 调用 accept () 函数,这个函数会检测监听文件描述符的读缓冲区

      • 检测不到数据,该函数阻塞

      • 如果检测到数据,解除阻塞,新的连接建立

  • 通信的文件描述符:

    • 客户端和服务器端都有通信的文件描述符

    • 发送数据:调用函数 write () /send (),数据进入到内核中

      • 数据并没有被发送出去,而是将数据写入到了通信的文件描述符对应的写缓冲区中

      • 内核检测到通信的文件描述符写缓冲区中有数据,内核会将数据发送到网络中

    • 接收数据:调用的函数 read () /recv (), 从内核读数据

      • 数据如何进入到内核程序猿不需要处理,数据进入到通信的文件描述符的读缓冲区中

      • 数据进入到内核,必须使用通信的文件描述符,将数据从读缓冲区中读出即可

1.4.2 客户端的通信流程

在单线程的情况下客户端通信的文件描述符有一个,没有监听的文件描述符

1.创建一个通信的套接字

int cfd = socket();

2.连接服务器,需要知道服务器绑定的 IP 和端口

 connect();

3.通信

 // 接收数据
 read(); / recv();
 // 发送数据
 write(); / send();

4.断开连接,关闭文件描述符 (套接字)

 close();

2.三次握手、四次挥手

TCP 协议是一个安全的、面向连接的、流式传输协议,所谓的面向连接就是三次握手,对于程序猿来说只需要在客户端调用 connect() 函数,三次握手就自动进行了。先通过下图看一下 TCP 协议的格式,然后再介绍三次握手的具体流程。

2.1 tcp 协议介绍

在 Tcp 协议中,比较重要的字段有:

  • 源端口:表示发送端端口号,字段长 16 位,2 个字节

  • 目的端口:表示接收端端口号,字段长 16 位,2 个字节

  • 序号(sequence number):字段长 32 位,占 4 个字节,序号的范围为 [0,4284967296]。

    • 由于 TCP 是面向字节流的,在一个 TCP 连接中传送的字节流中的每一个字节都按顺序编号

    • 首部中的序号字段则是指本报文段所发送的数据的第一个字节的序号,这是随机生成的。

    • 序号是循环使用的,当序号增加到最大值时,下一个序号就又回到了 0

  • 确认序号(acknowledgement number):占 32 位(4 字节),表示收到的下一个报文段的第一个数据字节的序号,如果确认序号为 N,序号为 S,则表明到序号 N-S 为止的所有数据字节都已经被正确地接收到了。

  • 8 个标志位(Flag):

  • CWR:CWR 标志与后面的 ECE 标志都用于 IP 首部的 ECN 字段,ECE 标志为 1 时,则通知对方已将拥塞窗口缩小;

  • ECE:若其值为 1 则会通知对方,从对方到这边的网络有阻塞。在收到数据包的 IP 首部中 ECN 为 1 时将 TCP 首部中的 ECE 设为 1.;

  • URG:该位设为 1,表示包中有需要紧急处理的数据,对于需要紧急处理的数据,与后面的紧急指针有关;

  • ACK:该位设为 1,确认应答的字段有效,TCP 规定除了最初建立连接时的 SYN 包之外该位必须设为 1;

  • PSH:该位设为 1,表示需要将收到的数据立刻传给上层应用协议,若设为 0,则先将数据进行缓存;

  • RST:该位设为 1,表示 TCP 连接出现异常必须强制断开连接;

  • SYN:用于建立连接,该位设为 1,表示希望建立连接,并在其序列号的字段进行序列号初值设定;

  • FIN:该位设为 1,表示今后不再有数据发送,希望断开连接

  • 窗口大小:该字段长 16 位,表示从确认序号所指位置开始能够接收的数据大小,TCP 不允许发送超过该窗口大小的数据。

2.2 三次握手

Tcp 连接是双向连接,客户端和服务器需要分别向对方发送连接请求,并且建立连接,三次握手成功之后,二者之间的双向连接也就成功建立了。如果要保证三次握手顺利完成,必须要满足以下条件:

  • 服务器端:已经启动,并且启动了监听(被动接受连接的一端)

  • 客户端:基于服务器端监听的 IP 和端口,向服务器端发起连接请求(主动发起连接的一端)

三次握手具体过程如下:

第一次握手:

  • 客户端:客户端向服务器端发起连接请求将报文中的SYN字段置为1,生成随机序号x,seq=x
  • 服务器端:接收客户端发送的请求数据,解析tcp协议,校验SYN标志位是否为1,并得到序号 x

第二次握手:

服务器端:给客户端回复数据

1.回复ACK, 将tcp协议ACK对应的标志位设置为1,表示同意了客户端建立连接的请求

2.回复了 ack=x+1, 这是确认序号

  • x: 客户端生成的随机序号
  • 1: 客户端给服务器发送的数据的量, SYN标志位存储到某一个字节中, 因此按照一个字节计算,表示客户端给服务器发送的1个字节服务器收到了。

3.将tcp协议中的SYN对应的标志位设置为 1, 服务器向客户端发起了连接请求

4.服务器端生成了一个随机序号 y, 发送给了客户端

  • 客户端:接收回复的数据,并解析tcp协议
  1. 校验ACK标志位,为1表示服务器接收了客户端的连接请求
  2. 数据校验,确认发送给服务器的数据服务器收到了没有,计算公式如下:发送的数据的量 = 使用服务器回复的确认序号 - 客户端生成的随机序号 ===> 1=x+1-x
  3. 校验SYN标志位,为1表示服务器请求和客户端建立连接
  4. 得到服务器生成的随机序号: y

第三次握手:

客户端:发送数据给服务器

1.将tcp协议中ACK标志位设置为1,表示同意了服务器的连接请求
2.给服务器回复了一个确认序号 ack = y+1

  • y:服务器端生成的随机序号
  • 1:服务器给客户端发送的数据量,服务器给客户端发送了ACK和SYN, 都存储在这一个字节中

3.发送给服务器的序号就是上一次从服务器端收的确认序号因此 seq = x+1
服务器端:接收数据, 并解析tcp协议

  1. 查看ACK对应的标志位是否为1, 如果是1代表, 客户端同意了服务器的连接请求
  2. 数据校验,确认发送给客户端的数据客户端收到了没有,计算公式如下:给客户端发送的数据量 = 确认序号 - 服务器生成的随机序号 ===> 1=y+1-y
  3. 得到客户端发送的序号:x+1
2.3 TCP 四次挥手

四次挥手是断开连接的过程,需要双向断开,关于由哪一端先断开连接是没有要求的。通信的两端如果想要断开连接就需要调用 close() 函数,当两端都调用了该函数,四次挥手也就完成了。

  • 客户端和服务器断开连接 -> 单向断开

  • 服务器和客户端断开连接 -> 单向断开

进行了两次单向断开,双向断开就完成了,每进行一次单向断开,就会完成两次挥手的动作。

基于上图的例子对四次挥手的具体过程进行阐述(实际上那端先断开连接都是允许的):

第一次挥手:

  • 主动断开连接的一方:发送断开连接的请求
  1. 将tcp协议中FIN标志位设置为1,表示请求断开连接
  2. 发送序号x给对端,seq=x,基于这个序号用于客户端数据校验的计算
  • 被动断开连接的一方:接收请求数据, 并解析TCP协议
  1. 校验FIN标志位是否为1
  2. 收到了序号 x,基于这个数据计算回复的确认序号 ack 的值

第二次挥手:

  • 被动断开连接的一方:回复数据
  1. 同意了对方断开连接的请求,将ACK标志位设置为1
  2. 回复 ack=x+1,表示成功接受了客户端发送的一个字节数据
  3. 向客户端发送序号 seq=y,基于这个序号用于服务器端数据校验的计算
  • 主动断开连接的一方:接收回复数据, 并解析TCP协议
  1. 校验ACK标志位,如果为1表示断开连接的请求对方已经同意了
  2. 校验 ack确认发送的数据服务器是否收到了,发送的数据 = ack - x = x + 1 -x = 1

第三次挥手:

  • 被动断开连接的一方:将tcp协议中FIN标志位设置为1,表示请求断开连接
  • 主动断开连接的一方:接收请求数据, 并解析TCP协议,校验FIN标志位是否为1

第四次挥手:

  • 主动断开连接的一方:回复数据
    • 将tcp协议中ACK对应的标志位设置为1,表示同意了断开连接的请求
    • ack=y+1,表示服务器发送给客户端的一个字节客户端接收到了
    • 序号 seq=h,此时的h应该等于 x+1,也就是第三次挥手时服务器回复的确认序号ack的值
  • 被动断开连接的一方:收到回复的ACK, 此时双向连接双向断开, 通信的两端没有任何关系了
2.4 流量控制

流量控制可以让发送端根据接收端的实际接受能力控制发送的数据量。它的具体操作是,接收端主机向发送端主机通知自己可以接收数据的大小,于是发送端会发送不会超过该大小的数据,该限制大小即为窗口大小,即窗口大小由接收端主机决定。

TCP 首部中,专门有一个字段来通知窗口大小,接收主机将自己可以接收的缓冲区大小放在该字段中通知发送端。当接收端的缓冲区面临数据溢出时,窗口大小的值也是随之改变,设置为一个更小的值通知发送端,从而控制数据的发送量,这样达到流量的控制。这个控制流程的窗口也可以称作滑动窗口。

左侧是数据发送端:对应的是发送端的写缓冲区 (内存),通过一个环形队列进行数据管理

  • 白色格子:空闲的内存,可以写数据

  • 粉色的格子:被写入到内存,但是还没有被发送出去的数据

  • 灰色的格子:代表已经被发送出去的数据

右侧是数据接收端:对应的是接收端的读缓冲区,存储发送端发送过来的数据

  • 白色格子:空闲的内存,可以继续接收数据,滑动窗口的值记录的就是白色的格子的大小

    • 随着接收的数据越来越多,白色格子越来越少,滑动窗口的值越来越小

    • 如果白色格子没有了,滑动窗口变为 0, 这时候,发送端就被阻塞了

  • 粉色格子:接收的数据,但是这个数据还没有从内核中读走,使用 read () /recv ()

    • 粉色格子变少了,可用空间就变多了,滑动窗口的值就变大了

    • 如果滑动窗口的值从 0 变为大于 0, 接收端又重新有容量接收数据了,发送端的阻塞自动解除,继续发送数据

3.TCP状态转换

3.1 TCP 状态转换

在 TCP 进行三次握手,或者四次挥手的过程中,通信的服务器和客户端内部会发生状态上的变化,发生的状态变化在程序中是看不到的,这个状态的变化也不需要程序猿去维护,但是在某些情况下进行程序的调试会去查看相关的状态信息。

3.2 半关闭

TCP 连接只有一方发送了 FIN,另一方没有发出 FIN 包,仍然可以在一个方向上正常发送数据,这中状态可以称之为半关闭或者半连接。当四次挥手完成两次的时候,就相当于实现了半关闭,在程序中只需要在某一端直接调用 close () 函数即可。套接字通信默认是双工的,也就是双向通信,如果进行了半关闭就变成了单工,数据只能单向流动了。

3.2 端口复用

在网络通信中,一个端口只能被一个进程使用,不能多个进程共用同一个端口。我们在进行套接字通信的时候,如果按顺序执行如下操作:先启动服务器程序,再启动客户端程序,然后关闭服务器进程,再退出客户端进程,最后再启动服务器进程,就会出如下的错误提示信息:bind error: Address already in use

通过 netstat 查看 TCP 状态,发现上一个服务器进程其实还没有真正退出。因为服务器进程是主动断开连接的进程,最后状态变成了 TIME_WAIT 状态,这个进程会等待 2msl(大约1分钟) 才会退出,如果该进程不退出,其绑定的端口就不会释放,再次启动新的进程还是使用这个未释放的端口,端口被重复使用,就是提示 bind error: Address already in use 这个错误信息。

4.服务器并发

4.1 单线程 / 进程

在 TCP 通信过程中,服务器端启动之后可以同时和多个客户端建立连接,并进行网络通信

在单线程 / 单进程场景下,服务器是无法处理多连接的,解决方案也有很多,常用的有三种:

使用多线程实现 使用多进程实现 使用 IO 多路转接(复用)实现 使用 IO 多路转接 + 多线程实现

4.2 多进程并发

如果要编写多进程版的并发服务器程序,首先要考虑,创建出的多个进程都是什么角色,这样就可以在程序中对号入座了。在 Tcp 服务器端一共有两个角色,分别是:监听和通信,监听是一个持续的动作,如果有新连接就建立连接,如果没有新连接就阻塞。关于通信是需要和多个客户端同时进行的,因此需要多个进程,这样才能达到互不影响的效果。进程也有两大类:父进程和子进程,通过分析我们可以这样分配进程:

  • 父进程:

    • 负责监听,处理客户端的连接请求,也就是在父进程中循环调用 accept() 函数

    • 创建子进程:建立一个新的连接,就创建一个新的子进程,让这个子进程和对应的客户端通信

    • 回收子进程资源:子进程退出回收其内核 PCB 资源,防止出现僵尸进程

  • 子进程:负责通信,基于父进程建立新连接之后得到的文件描述符,和对应的客户端完成数据的接收和发送。

    • 发送数据:send() / write()

    • 接收数据:recv() / read()

在多进程版的服务器端程序中,多个进程是有血缘关系,对应有血缘关系的进程来说,还需要想明白他们有哪些资源是可以被继承的,哪些资源是独占的,以及一些其他细节:

  • 子进程是父进程的拷贝,在子进程的内核区 PCB 中,文件描述符也是可以被拷贝的,因此在父进程可以使用的文件描述符在子进程中也有一份,并且可以使用它们做和父进程一样的事情。

  • 父子进程有用各自的独立的虚拟地址空间,因此所有的资源都是独占的

  • 为了节省系统资源,对于只有在父进程才能用到的资源,可以在子进程中将其释放掉,父进程亦如此。

  • 由于需要在父进程中做 accept() 操作,并且要释放子进程资源,如果想要更高效一下可以使用信号的方式处理

4.3 多线程并发

编写多线程版的并发服务器程序和多进程思路差不多,考虑明白了对号入座即可。多线程中的线程有两大类:主线程(父线程)和子线程,他们分别要在服务器端处理监听和通信流程。根据多进程的处理思路,就可以这样设计了:

  • 主线程:

    • 负责监听,处理客户端的连接请求,也就是在父进程中循环调用 accept() 函数

    • 创建子线程:建立一个新的连接,就创建一个新的子进程,让这个子进程和对应的客户端通信

    • 回收子线程资源:由于回收需要调用阻塞函数,这样就会影响 accept(),直接做线程分离即可。

  • 子线程:负责通信,基于主线程建立新连接之后得到的文件描述符,和对应的客户端完成数据的接收和发送。

    • 发送数据:send() / write()

    • 接收数据:recv() / read()

在多线程版的服务器端程序中,多个线程共用同一个地址空间,有些数据是共享的,有些数据的独占的,下面来分析一些其中的一些细节:

  • 同一地址空间中的多个线程的栈空间是独占的

  • 多个线程共享全局数据区,堆区,以及内核区的文件描述符等资源,因此需要注意数据覆盖问题,并且在多个线程访问共享资源的时候,还需要进行线程同步。

5.TCP数据粘包的处理

 5.1背锅侠

因为数据的传输是基于流的所以发送端和接收端每次处理的数据的量,处理数据的频率可以不是对等的,可以按照自身需求来进行决策。

TCP 协议是优势非常明显,但是有时也会给我们造成困扰,正所谓:成也萧何败萧何。假设我们有如下需求:

客户端和服务器之间要进行基于 TCP 的套接字通信

  • 通信过程中客户端会每次会不定期给服务器发送一个不定长度的有特定含义的字符串。

  • 通信的服务器端每次都需要接收到客户端这个不定长度的字符串,并对其进行解析

根据上面的描述,服务器在接收数据的时候有如下几种情况:

  1. 一次接收到了客户端发送过来的一个完整的数据包

  2. 一次接收到了客户端发送过来的 N 个数据包,由于每个包的长度不定,无法将各个数据包拆开

  3. 一次接收到了一个或者 N 个数据包 + 下一个数据包的一部分,还是很悲剧,无法将数据包拆开

  4. 一次收到了半个数据包,下一次接收数据的时候收到了剩下的一部分 + 下个数据包的一部分,更悲剧,头大了

  5. 另外,还有一些不可抗拒的因素:比如客户端和服务器端的网速不一样,发送和接收的数据量也会不一致

对于以上描述的现象很多时候我们将其称之为 TCP的粘包问题,但是这种叫法不太对的,本身 TCP 就是面向连接的流式传输协议,特性如此,我们却说是 TCP 这个协议出了问题,这只能说是使用者的无知。多个数据包粘连到一起无法拆分是我们的需求过于复杂造成的,是程序猿的问题而不是协议的问题,TCP 协议表示这锅它不想背。

现在问题来了,服务器端如果想保证每次都能接收到客户端发送过来的这个不定长度的数据包,程序猿应该如何解决这个问题呢?下面给大家提供几种解决方案:

  1. 使用标准的应用层协议(比如:http、https)来封装要传输的不定长的数据包

  2. 在每条数据的尾部添加特殊字符,如果遇到特殊字符,代表当条数据接收完毕了 有缺陷:效率低,需要一个字节一个字节接收,接收一个字节判断一次,判断是不是那个特殊字符串

  3. 在发送数据块之前,在数据块最前边添加一个固定大小的数据头,这时候数据由两部分组成:数据头 + 数据块 数据头:存储当前数据包的总字节数,接收端先接收数据头,然后在根据数据头接收对应大小的字节 数据块:当前数据包的内容

5.2解决方案

如果使用 TCP 进行套接字通信,如果发送的数据包粘连到一起导致接收端无法解析,我们通常使用添加包头的方式轻松地解决掉这个问题。关于数据包的包头大小可以根据自己的实际需求进行设定,这里没有啥特殊需求,因此规定包头的固定大小为4个字节,用于存储当前数据块的总字节数。

5.2.1 发送端

对于发送端来说,数据的发送分为 4 步

  1. 根据待发送的数据长度 N 动态申请一块固定大小的内存:N+4(4 是包头占用的字节数)

  2. 将待发送数据的总长度写入申请的内存的前四个字节中,此处需要将其转换为网络字节序(大端)

  3. 将待发送的数据拷贝到包头后边的地址空间中,将完整的数据包发送出去(字符串没有字节序问题)

  4. 释放申请的堆内存。

由于发送端每次都需要将这个数据包完整的发送出去,因此可以设计一个发送函数,如果当前数据包中的数据没有发送完就让它一直发送,处理代码如下:

关于数据的发送最后再次强调:字符串没有字节序问题,但是数据头不是字符串是整形,因此需要从主机字节序转换为网络字节序再发送。

5.2.2 接收端

了解了套接字的发送端如何发送数据,接收端的处理步骤也就清晰了,具体过程如下:

  1. 首先接收 4 字节数据,并将其从网络字节序转换为主机字节序,这样就得到了即将要接收的数据的总长度

  2. 根据得到的长度申请固定大小的堆内存,用于存储待接收的数据

  3. 根据得到的数据块长度接收固定数目的数据保存到申请的堆内存中

  4. 处理接收的数据

  5. 释放存储数据的堆内存

从数据包头解析出要接收的数据长度之后,还需要将这个数据块完整的接收到本地才能进行后续的数据处理,因此需要编写一个接收数据的功能函数,保证能够得到一个完整的数据包数据,处理函数实现如下

6.套接字通信类的封装

在掌握了基于 TCP 的套接字通信流程之后,为了方便使用,提高编码效率,可以对通信操作进行封装,本着有浅入深的原则,先基于 C 语言进行面向过程的函数封装,然后再基于 C++ 进行面向对象的类封装。

7.IO多路转接(复用)

IO多路转接(复用)用来处理并发的(只有一个进程或一个线程时使用)

IO 多路转接也称为 IO 多路复用,它是一种网络通信的手段(机制),通过这种方式可以同时监测多个文件描述符并且这个过程是阻塞的,一旦检测到有文件描述符就绪( 可以读数据或者可以写数据)程序的阻塞就会被解除,之后就可以基于这些(一个或多个)就绪的文件描述符进行通信了。通过这种方式在单线程 / 进程的场景下也可以在服务器端实现并发。常见的 IO 多路转接方式有:select、poll、epoll。

文件描述符受内核控制

与多进程和多线程技术相比,I/O 多路复用技术的最大优势是系统开销小,系统不必创建进程 / 线程,也不必维护这些进程 / 线程,从而大大减小了系统的开销。

7.1 IO多路转接(复用)之select

select可以跨平台,底层是线性表,重点掌握

7.1.1 函数原型

使用 select 这种 IO 多路转接方式需要调用一个同名函数 select,这个函数是跨平台的,Linux、Mac、Windows 都是支持的。程序猿通过调用这个函数可以委托内核帮助我们检测若干个文件描述符的状态,其实就是检测这些文件描述符对应的读写缓冲区的状态:

读缓冲区:检测里边有没有数据,如果有数据该缓冲区对应的文件描述符就绪 写缓冲区:检测写缓冲区是否可以写 (有没有容量),如果有容量可以写,缓冲区对应的文件描述符就绪 读写异常:检测读写缓冲区是否有异常,如果有该缓冲区对应的文件描述符就绪 委托检测的文件描述符被遍历检测完毕之后,已就绪的这些满足条件的文件描述符会通过 select() 的参数分 3 个集合传出,程序猿得到这几个集合之后就可以分情况依次处理了。

7.2 IO 多路转接(复用)之 poll

poll只能在Linux下使用,底层是线性表

poll 的机制与 select 类似,与 select 在本质上没有多大差别,使用方法也类似,下面的是对于二者的对比:

  • 内核对应文件描述符的检测也是以线性的方式进行轮询,根据描述符的状态进行处理

  • poll 和 select 检测的文件描述符集合会在检测过程中频繁的进行用户区和内核区的拷贝,它的开销随着文件

  • 描述符数量的增加而线性增大,从而效率也会越来越低。

  • select检测的文件描述符个数上限是1024,poll没有最大文件描述符数量的限制

  • select可以跨平台使用,poll只能在Linux平台使用

7.3 IO 多路转接(复用)之 epoll

epoll只能在Linux下使用,底层是红黑树,重点掌握

epoll 全称 eventpoll,是 linux 内核实现 IO 多路转接 / 复用(IO multiplexing)的一个实现。IO 多路转接的意思是在一个操作里同时监听多个输入输出源,在其中一个或多个输入输出源可用的时候返回,然后对其的进行读写操作。epoll 是 select 和 poll 的升级版,相较于这两个前辈,epoll 改进了工作方式,因此它更加高效

  • 对于待检测集合select和poll是基于线性方式处理的,epoll是基于红黑树来管理待检测集合的。

  • select和poll每次都会线性扫描整个待检测集合,集合越大速度越慢,epoll使用的是回调机制,效率高,处理效率也不会随着检测集合的变大而下降

  • select和poll工作过程中存在内核/用户空间数据的频繁拷贝问题,在epoll中内核和用户区使用的是共享内存(基于mmap内存映射区实现),省去了不必要的内存拷贝。

  • 程序猿需要对select和poll返回的集合进行判断才能知道哪些文件描述符是就绪的,通过epoll可以直接得到已就绪的文件描述符集合,无需再次检测

  • 使用 epoll 没有最大文件描述符的限制,仅受系统中进程能打开的最大文件数目限制

  • 当多路复用的文件数量庞大、IO 流量频繁的时候,一般不太适合使用 select () 和 poll (),这种情况下 select () 和 poll () 表现较差,推荐使用 epoll ()。

8.基于 UDP 的套接字通信

 udp 是一个面向无连接的,不安全的,报式传输层协议,udp 的通信过程默认也是阻塞的。

  • UDP通信不需要建立连接 ,因此不需要进行 connect () 操作

  • UDP通信过程中,每次都需要指定数据接收端的IP和端口,和发快递差不多

  • UDP不对收到的数据进行排序,在UDP报文的首部中并没有关于数据顺序的信息

  • UDP对接收到的数据报不回复确认信息,发送端不知道数据是否被正确接收,也不会重发数据。

  • 如果发生了数据丢失,不存在丢一半的情况,如果丢当前这个数据包就全部丢失了

8.1 通信流程

使用 UDP 进行通信,服务器和客户端的处理步骤比 TCP 要简单很多,并且两端是对等的 (通信的处理流程几乎是一样的),也就是说并没有严格意义上的客户端和服务器端。UDP 的通信流程如下:

8.1.1 服务器端

假设服务器端是接收数据的角色:

1.创建通信的套接字

 // 第二个参数是 SOCK_DGRAM, 第三个参数0表示使用报式协议中的udp
 int fd = socket(AF_INET, SOCK_DGRAM, 0);

2.使用通信的套接字和本地的 IP 和端口绑定,IP 和端口需要转换为大端 (可选)

 bind();

3.通信

 // 接收数据
 recvfrom();
 // 发送数据
 sendto();

4.关闭套接字(文件描述符)

 close(fd);
8.1.2 客户端

假设客户端是发送数据的角色:

1.创建通信的套接字

 // 第二个参数是 SOCK_DGRAM, 第三个参数0表示使用报式协议中的udp
 int fd = socket(AF_INET, SOCK_DGRAM, 0);

2.通信

 // 接收数据
 recvfrom();
 // 发送数据
 sendto();

3.关闭套接字(文件描述符)

 close(fd);

在 UDP 通信过程中,哪一端是接收数据的角色,那么这个接收端就必须绑定一个固定的端口,如果某一端不需要接收数据,这个绑定操作就可以省略不写了,通信的套接字会自动绑定一个随机端口。

9.UDP 之广播

9.1 广播的特点

广播的 UDP 的特性之一,通过广播可以向子网中多台计算机发送消息,并且子网中所有的计算机都可以接收到发送方发送的消息,每个广播消息都包含一个特殊的 IP 地址,这个 IP 中子网内主机标志部分的二进制全部为 1 (即点分十进制 IP 的最后一部分是 255)。点分十进制的 IP 地址每一部分是 1 字节,最大值为 255,比如:192.168.1.100

  • 前两部分 192.168 表示当前网络是局域网

  • 第三部分 1 表示局域网中的某一个网段,最大值为 255

  • 第四部分 100 用于标记当前网段中的某一台主机,最大值为 255

  • 每个网段都有一个特殊的广播地址,即:192.168.xxx.255

广播分为两端,即数据发送端和数据接收端,通过广播的方式发送数据,发送端和接收端的关系是 1:N

  • 发送广播消息的一端,通过广播地址,可以将消息同时发送到局域网的多台主机上(数据接收端)

  • 在发送广播消息的时候,必须要把数据发送到广播地址上

  • 广播只能在局域网内使用,广域网是无法使用UDP进行广播的

  • 只要发送端在发送广播消息,数据接收端就能收到广播消息,消息的接收是无法拒绝的,除非将接收端的进程关闭,就接收不到了。

UDP 的广播和日常生活中的广播是一样的,都是一种快速传播消息的方式,因此广播的开销很小,发送端使用一个广播地址,就可以将数据发送到多个接收数据的终端上,如果不使用广播,就需要进行多次发送才能将数据分别发送到不同的主机上。

10.UDP 之组播(多播)

10.1 组播的特点

组播也可以称之为多播这也是 UDP 的特性之一。组播是主机间一对多的通讯模式,是一种允许一个或多个组播源发送同一报文到多个接收者的技术。组播源将一份报文发送到特定的组播地址,组播地址不同于单播地址,它并不属于特定某个主机,而是属于一组主机。一个组播地址表示一个群组,需要接收组播报文的接收者都加入这个群组。

  • 广播只能在局域网访问内使用,组播既可以在局域网中使用,也可以用于广域网

  • 在发送广播消息的时候,连接到局域网的客户端不管想不想都会接收到广播数据,组播可以控制发送端的消息能够被哪些接收端接收,更灵活和人性化。

  • 广播使用的是广播地址,组播需要使用组播地址。

  • 广播和组播属性默认都是关闭的,如果使用需要通过 setsockopt () 函数进行设置。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值