Network Programming Final

本章为网络编程 —— C语言Socket编程的期末复习专题。

Foundation

在这一节中,将复习一些计算机网络基础知识,以及网络编程的概念性知识。

计算机网络是什么?

根据维基百科的解释:计算机网络是一组共享网络节点上的资源或由网络节点提供的资源的计算机。A computer network is a set of computers sharing resources located on or provided by network nodes.

它包含了Computer (包含Client和Server),Network Equipment(包含Switch, Router等),Transmission Medium (Wired & Wireless)。

 

什么是IP协议?TCP/UDP协议是什么?端口(Port)是什么?

IP(Internet Protocol) 是网际协议中用于标识发送或接收数据报的设备的一串数字。IP地址有两个主要功能:

  1. 寻址 (Addressing): IP地址用于标识其网络接口,并且提供主机在网络中的位置,以便。
  2. 路由 (Routing): IP协议负责决定数据包的下一个“中转站”是哪里,确保数据包能够沿着正确的路径到达目的地。

 

TCP协议 (Transmission Control Protocol) 和 UDP协议 (User Datagram Protocol)。 这两个协议都属于传输层协议,它们负责在网络中的两台计算机之间传输数据。

  • TCP/IP协议提供了可靠传输 (Reliable)面向连接 (Connection-oriented)的数据传输,真正开始传输数据之前,TCP需要在发送方和接收方之间建立一个“连接”,并具有重传和重新排序机制。
    如果对数据的可靠性要求很高,比如文件传输、网页浏览等,我们会选择TCP协议。 因为我们希望确保收到的数据是完整且正确的。
  • 而UDP则是一种无连接 (Connectionless)不可靠传输 (Unreliable)。UDP在传输数据之前不需要建立连接,直接将数据发送出去。UDP不保证数据一定能够到达目的地,也不保证数据的顺序。
    如果对数据的实时性要求很高,而且允许少量的数据丢失,比如在线视频、网络游戏等,我们会选择UDP协议。 因为速度更快,即使丢一些数据,对整体体验的影响可能不大。

 

每台计算机上可以运行多个网络应用程序,每个应用程序都需要一个唯一的端口号来与其他计算机上的应用程序进行通信。端口 (Port) 号是一个0到65535之间的数字。端口的作用就是区分同一台计算机上的不同网络应用程序

  • 0 到 1023 的端口号是知名端口 (well-known ports),通常由系统服务占用(例如 HTTP 的 80 端口,FTP 的 21 端口)。
  • 1024 到 49151 的端口号是注册端口 (registered ports),可以由应用程序注册使用。
  • 49152 到 65535 的端口号是动态或私有端口 (dynamic or private ports),通常由客户端程序临时使用。

 

它们之间的关系:

  • IP地址 就像是房屋的地址,用来确定数据要发送到哪一台计算机。
  • TCP/UDP协议 就像是选择哪种邮寄方式(挂号信还是普通信件),决定了数据如何可靠或快速地传输。
  • 端口号 就像是房屋的房间号,用来确定数据要发送到目标计算机上的哪个应用程序。

它们共同协作,完成网络数据的传输过程:

  1. 发送方知道接收方的IP地址(房屋地址)和端口号(房间号),并选择使用TCP或UDP协议(邮寄方式)。
  2. 数据包在互联网上根据IP地址被路由到目标计算机。
  3. 目标计算机接收到数据包后,根据数据包中的端口号,将数据交给对应的应用程序处理。

 

Socket是什么?Socket由什么元素唯一确定?

Socket是操作系统提供的一种编程接口,应用程序可以通过这个接口来发送和接收网络数据。可以把Socket想象成应用程序之间进行网络通信的端点。就像打电话时需要手机一样,网络上的应用程序进行通信也需要Socket。

一个Socket的唯一标识由以下几个元素组成:

  1. IP地址 (IP Address): 指定了通信的一方所在的计算机。
  2. 端口号 (Port Number): 指定了计算机上的哪个应用程序正在进行通信。
  3. 传输层协议 (Transport Protocol): 指定了使用的传输协议,通常是TCP或UDP。

 

TCP Socket 和 UDP Socket 有什么区别?

对于TCP Socket来说,通信的双方都需要创建Socket。 一方作为服务器 (Server),监听特定的IP地址和端口号,等待客户端的连接;另一方作为客户端 (Client),主动连接服务器的IP地址和端口号。

对于UDP Socket来说,通信的双方也需要创建Socket,但不需要像TCP那样先建立连接,可以直接发送数据包。

 

Socket位于网络架构,以及空间中的什么位置?

Socket位于传输层应用层之间,是连接这两层的桥梁。

它提供了应用程序访问网络服务的接口,并对底层的网络细节进行了抽象。应用程序通过 Socket 可以使用 TCP 或 UDP 协议进行网络通信。

 

在空间上,Socket处于内核空间和用户空间的中间层。 Socket的接口 (API) 存在于 用户空间 (User Space),而 Socket的实际实现和管理 则发生在 内核空间 (Kernel Space)

  • 用户空间 (User Space): 是应用程序运行的区域,不能直接访问硬件资源,需要通过系统调用来请求内核服务。可以比作是用户的家,如果你要发邮件,需要通过邮局(内核空间)才能寄出或收到邮件。
  • 内核空间 (Kernel Space) : 是操作系统内核运行的区域,具有最高的权限。可以直接访问硬件资源。可以比作一个邮局,负责处理邮件的收发和分拣。

 

常见的应用层、传输层、网络层协议分别有哪些,有什么用?

应用层协议是网络协议栈的最顶层,它直接为应用程序提供服务。下面是一些常见的协议:

  • HTTP (Hypertext Transfer Protocol - 超文本传输协议): HTTP 是用于在Web浏览器和Web服务器之间传输信息的协议。
  • FTP (File Transfer Protocol - 文件传输协议): FTP 是用来在计算机之间传输文件的协议。
  • SMTP (Simple Mail Transfer Protocol - 简单邮件传输协议): SMTP 是用于发送电子邮件的协议。
  • DNS (Domain Name System - 域名系统): DNS负责将域名解析为IP地址。

网络层协议主要负责在网络中路由数据包。

  • RIP (Routing Information Protocol - 路由信息协议): 这是一种内部网关协议 (IGP),用于在自治系统 (Autonomous System) 内部的路由器之间交换路由信息。RIP用于在小型网络中进行路由选择。
  • SNMP (Simple Network Management Protocol - 简单网络管理协议): SNMP用于网络设备的监控和管理。网络管理员可以使用SNMP来收集网络设备的信息(如CPU使用率、内存使用率、接口状态等),并对设备进行配置和控制。SNMP用于网络设备的监控和管理。
  • ARP (Address Resolution Protocol - 地址解析协议): ARP的作用就是根据IP地址查找对应的MAC地址ARP用于将IP地址解析为MAC地址。
  • IGMP (Internet Group Management Protocol - 互联网组管理协议): IGMP用于管理多播组成员。当网络中的主机想要加入或离开一个多播组时,它会使用IGMP协议向路由器报告。路由器会根据这些报告来维护多播组成员的信息,并将多播数据包发送给所有组成员。IGMP用于管理多播组成员。
  • ICMP (Internet Control Message Protocol - 互联网控制消息协议): ICMP用于在IP主机和路由器之间传递控制消息。例如,我们常用的ping命令就是使用了ICMP协议来测试网络连接是否畅通。当网络出现问题时,路由器也可能使用ICMP协议向源主机发送错误报告。ICMP用于传递网络控制消息和错误报告。

image-20241228101340588

 

IP地址和MAC地址有什么区别?IP地址是全局唯一的,为什么还需要MAC地址?

  • 地址上:
    • IP地址是逻辑上的地址,它是由网络管理员或互联网服务提供商 (ISP) 分配的。就像是家庭住址一样,是可以改变的。
    • MAC地址是硬件级别的地址,它被固化在网卡 (Network Interface Card, NIC) 的 ROM 芯片中。 就像是您的身份证号码一样,通常情况下是全球唯一的
  • 层级上:
    • IP地址工作在 OSI 模型的第三层 (网络层)。 它主要用于在不同的网络之间进行路由。
    • MAC地址工作在 OSI 模型的第二层 (数据链路层)。 它主要用于在同一个局域网 (LAN) 内进行通信。
  • 寻址上:
    • 当数据包需要跨越不同的网络传输时,设备会使用目标设备的IP地址来找到目标网络。就像在不同的城市之间找到某个城市一样。
    • 当数据包需要在同一个局域网内传输时,设备会使用目标设备的MAC地址来找到它。就像在同一个小区里找到某栋房子一样。

 

IP 地址负责跨网络寻址,而 MAC 地址负责本地网络寻址。即使 IP 地址发生变化,设备仍然可以通过其唯一的 MAC 地址被识别。

例如:如果目标设备不在同一本地网络中,路由器会将数据包转发到下一个路由器,直到到达目标网络。在每次转发的过程中,数据包的 IP 地址保持不变,但 MAC 地址会根据本地网络的不同而改变。

 

TCP 的握手过程是怎么样的?

TCP建立连接时需要进行三次握手(Three-way handshake),在断开连接时需要四次挥手。

三次握手

  • 初始状态:客户端处于 closed状态,服务器处于 listen 状态。
  • 第一次握手:客户端发送请求报文将初始化序列号seq = x发送给服务端,验证了客户端的发送能力和服务端的接收能力.
  • 第二次握手:服务端受到 SYN 请求报文之后,如果同意连接,会以自己的初始化序列号 seq = y和确认序列号(期望下次收到的数据包)ack = x + 1报文作为应答,服务器为receive状态。
  • 第三次握手: 客户端接收到服务端的 SYN + ACK之后,知道可以下次可以发送了下一序列的数据包了,然后发送同步序列号 ack = y + 1和数据包的序列号 seq = x + 1作为应答,客户端转为established状态。

第一步:SYN

  • 客户端:发送一个SYN(Synchronize)报文段给服务器,请求建立连接。报文段中包含一个初始序列号。
  • 服务器:等待并接收客户端的SYN报文段。

第二步:SYN-ACK

  • 服务器:接收到客户端的SYN报文段后,回复一个SYN-ACK(Synchronize-Acknowledgment)报文段,表示同意建立连接。报文段中包含服务器的初始序列号和对客户端SYN报文段的确认号。
  • 客户端:等待并接收服务器的SYN-ACK报文段。

第三步:ACK

  • 客户端:接收到服务器的SYN-ACK报文段后,回复一个ACK(Acknowledgment)报文段,表示确认连接建立。报文段中包含对服务器SYN-ACK报文段的确认号。
  • 服务器:接收到客户端的ACK报文段后,连接建立完成。

image-20241228102936773

四次挥手

  • 客户端发送FIN报文,表示客户端不再发送数据,请求断开连接。
  • 服务端接收到FIN报文后,向客户端发送ACK报文,表示服务端已经接收到客户端的请求,并准备好断开连接。此时等待数据发送完毕。
  • 服务端发送FIN报文,表示服务端不再发送数据,请求断开连接。
  • 客户端接收到服务端的FIN报文后,向服务端发送ACK报文,表示客户端已经接收到服务端的请求,并断开连接。

 

大端序 (Big-Endian) 和小根序 (Little-Endian) 有什么区别?

字节序指的是计算机系统中存储多字节数据类型(例如 int, short, long 等)时,字节的排列顺序。对于一个多字节的数据,它由多个字节组成,这些字节在内存中可以按照不同的顺序排列。主要有两种排列方式:大端序和小端序。

  1. 大端序(Big-Endian): 在大端序系统中,数据的最高有效字节(Most Significant Byte, MSB)存储在最低的内存地址,而最低有效字节(Least Significant Byte, LSB)存储在最高的内存地址。假设一个 32 位的整数 0x12345678(十六进制表示)。0x12 是最高有效字节。0x78 是最低有效字节。
  2. 小端序(Little-Endian): 在小端序系统中,数据的最低有效字节(LSB)存储在最低的内存地址,而最高有效字节(MSB)存储在最高的内存地址。同样的例子,0x78 是最高有效字节。0x12 是最低有效字节。
// Big-Endian
内存地址   数据
-----------------
0x1000     0x12  (MSB)
0x1001     0x34
0x1002     0x56
0x1003     0x78  (LSB)

// Little-Endian
内存地址   数据
-----------------
0x1000     0x78  (LSB)
0x1001     0x56
0x1002     0x34
0x1003     0x12  (MSB)

为了解决跨平台网络通信中的字节序问题,通常会使用以下函数进行字节序的转换:

  • 主机到网络字节序:
    • uint32_t htonl(uint32_t hostlong); 将 32 位无符号整数从主机字节序转换为网络字节序。
    • uint16_t htons(uint16_t hostshort); 将 16 位无符号整数从主机字节序转换为网络字节序。
  • 网络到主机字节序:
    • uint32_t ntohl(uint32_t netlong); 将 32 位无符号整数从网络字节序转换为主机字节序。
    • uint16_t ntohs(uint16_t netshort); 将 16 位无符号整数从网络字节序转换为主机字节序。

只要你在发送或接收涉及到端口号IP 地址的数字表示时,就需要考虑使用字节序转换函数。对于其他数据,例如字符串或单字节数据,则不需要进行转换。

image-20241228121021840

 

如果想要并发 (Concurrently) 处理多个请求,我们一般可以使用哪些方法?

我们可以使用如下方法:

  • Per-client processes:每一个客户端使用一个进程去处理。
  • Per-client threads:每一个客户端使用一个线程去处理。
  • Multiplexing:使用多路复用技术。

后续会具体解析。

 

程序 (Program) 和进程 (Process) 之间有什么区别?

  • 程序是一组静态的指令集合,描述了如何完成特定的任务。
  • 进程是程序的一次动态执行过程。
特性 程序 (Program) 进程 (Process)
本质 静态的指令集合 动态的执行实例
状态 被动的,存储在磁盘上 主动的,正在运行
生命周期 长期存在,直到被删除 短暂存在,从启动到结束
资源 不占用系统资源(除了存储空间) 占用系统资源(内存、CPU 时间等)
关系 一个程序可以创建多个进程 一个进程对应一个正在执行的程序

image-20241228144834233

 


TCP/IP Sockets

TCP客户端和服务器之间的交互过程大致可以描述为如下步骤和流程图:

客户端 (Client):

  1. 创建Socket (socket()): 客户端也需要调用 socket() 函数创建一个Socket。
  2. 连接服务器 (connect()): 客户端调用 connect() 函数,指定服务器的IP地址和端口号,向服务器发起连接请求。
  3. 发送/接收数据 (send()/recv()): 连接建立后,客户端可以使用 write() 函数向服务器发送数据,使用 read() 函数接收来自服务器的数据。
  4. 关闭连接 (close()): 通信结束后,客户端关闭连接。

 

服务器端 (Server):

  1. 创建Socket (socket()): 服务器首先调用 socket() 函数创建一个新的Socket。
  2. 绑定地址和端口 (bind()): 服务器将创建的Socket与一个特定的IP地址和端口号绑定,这样客户端才能找到它。
  3. 监听连接 (listen()): 服务器开始监听指定端口上的连接请求。
  4. 接受客户端连接 (accept()): 当有客户端发起连接请求时,服务器调用 accept() 函数接受连接。 accept() 会创建一个新的Socket用于与该客户端进行通信。
  5. 接收/发送数据 (read()/write()): 服务器使用 read() 函数接收来自客户端的数据,使用 write() 函数向客户端发送数据。
  6. 关闭连接 (close()): 通信结束后,服务器关闭与客户端的连接。

tcpip_socket_flow

在进行Socket编程时,需要使用特定的数据结构来表示网络地址。这些结构体包含了进行网络通信所需的地址信息,例如IP地址和端口号。 最常用的Socket地址结构是 sockaddr_in

#include <netinet/in.h>

struct sockaddr_in {
    sa_family_t    sin_family;  // 地址族,通常为 AF_INET
    in_port_t      sin_port;    // 端口号 (网络字节序)
    struct in_addr sin_addr;    // IPv4 地址
    unsigned char  sin_zero[8]; // 填充字节,通常设置为 0
};

struct in_addr {
    in_addr_t s_addr;       // IPv4 地址 (网络字节序)
};
  • sin_family: 指定地址族,对于IPv4,它总是设置为 AF_INET
  • sin_port: 存储端口号。端口号需要转换为网络字节序 (大端序),可以使用 htons() 函数进行转换。
  • sin_addr: 这是一个结构体,用于存储32位的IPv4地址。也需要转换为网络字节序,可以使用 inet_addr() 函数将点分十进制的IP地址字符串转换为网络字节序的整数,或者使用 htonl() 函数转换一个主机字节序的整数IP地址。
  • sin_zero: 这是一个填充字节数组,长度为8个字节,通常设置为0

我们一般在绑定(Bind)之前,需要设置好socket的基本信息,下面是一个设置的例子:

int main() {
    struct sockaddr_in server_addr;
    // 设置地址族为 IPv4
    server_addr.sin_family = AF_INET;
    // 设置端口号为 8080 (转换为网络字节序)
    server_addr.sin_port = htons(8080);
    // 使用 INADDR_ANY 监听所有IP接口
    server_addr.sin_addr.s_addr = htonl(INADDR_ANY);

    printf("  端口号: %d\n", ntohs(server_addr.sin_port)); // 转换回主机字节序打印
    printf("  IP地址: %s\n", inet_ntoa(server_addr.sin_addr)); // 转换回点分十进制打印
    return 0;
}

在设置好基本信息后,我们使用 socket() 系统调用来创建一个Socket。

#include <sys/socket.h>

int socket(int family, int type, int protocol);
  • family (族): 指定Socket使用的协议族。常用的值有:
    • AF_INET: IPv4 互联网协议族。
    • AF_INET6: IPv6 互联网协议族。
    • AF_UNIX: 本地Socket (用于同一主机上的进程间通信)。
  • type (类型): 指定Socket的类型,定义了数据传输的特性。常用的值有:
    • SOCK_STREAM: 提供可靠的、面向连接的字节流服务。 用于 TCP 协议。
    • SOCK_DGRAM: 提供不可靠的、无连接的数据报服务。 用于 UDP 协议。
    • SOCK_RAW: 提供原始套接字,允许程序直接访问IP协议层。
  • protocol (协议): 指定在域和类型确定的情况下使用的特定协议。 通常设置为 0,表示使用默认协议。

下面是一个创建Socket的例子:

int main() {
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock == -1) {
        perror("socket creation error");
        return 1;
    }
}

socket()函数成功创建时: 返回一个非负整数,表示新创建的Socket的文件描述符。应用程序可以使用这个文件描述符来引用该Socket。错误则返回 -1

 

服务器端创建Socket之后,我们需要将这个Socket与一个特定的本地地址(IP地址和端口号)关联起来。这个过程称为绑定 (binding)

bind() 函数将服务器的Socket与本地地址和端口号绑定,使服务器能够被客户端找到。

bind() 函数的原型如下:

#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd: 这是由 socket() 函数返回的Socket文件描述符,代表了我们创建的Socket。
  • addr: 这是一个指向 sockaddr 结构体的指针,包含了要绑定的本地地址信息(IP地址和端口号)。 由于此处是通用结构,需要强制类型转换为 sockaddr *
  • addrlen: 指定了 addr 指向的结构体的大小,以字节为单位。 可以使用 sizeof(struct sockaddr_in)来获取。

下面是一个绑定的例子:

// server.c
int main() {
    // Initialize the serv_addr structure
    struct sockaddr_in server_addr;
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 绑定到本地回环地址
    server_addr.sin_port = htons(8080); // 绑定到 8080 端口
    memset(server_addr.sin_zero, '\0', sizeof(server_addr.sin_zero));  // 设置填充位,会自动完成
    
    // Create socket
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock == -1) {
        perror("socket creation error");
        return 1;
    }
    
    // Bind Socket
    if (bind(sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind error");
        return 1;
    }
}

同理的,当bind()函数返回为-1时,绑定失败。大多数情况为指定的地址和端口已经被其他Socket占用。

 

在服务器端成功调用 bind() 函数之后,我们需要让Socket进入监听 (listening) 状态,准备接受客户端的连接请求。

listen() 函数让服务器的Socket进入监听状态,准备接受客户端的连接请求。

listen() 函数的原型如下:

#include <sys/socket.h>

int listen(int sockfd, int backlog);
  • sockfd: 这是已经绑定了本地地址的Socket文件描述符。
  • backlog: 指定了连接请求队列 (listen queue)的最大长度。 当服务器正在处理一个客户端连接时,可能会有其他客户端尝试连接。 这些连接请求会被放入队列中等待处理。
    • 如果队列已满,新的连接请求将会被拒绝,客户端会收到 ECONNREFUSED 错误。
    • backlog 的具体值取决于操作系统,通常在 <sys/socket.h> 中定义了一个建议的最大值 SOMAXCONN。 可以将其设置为 SOMAXCONN 或一个较小的合理值。

下面是一个监听的实现例子:

// server.c
int main() {
    // Initialize the serv_addr structure
    struct sockaddr_in server_addr;
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 绑定到本地回环地址
    server_addr.sin_port = htons(8080); // 绑定到 8080 端口
    memset(server_addr.sin_zero, '\0', sizeof(server_addr.sin_zero));  // 设置填充位,会自动完成
    
    // Create socket
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock == -1) {
        perror("socket creation error");
        return 1;
    }
    
    // Bind Socket
    if (bind(sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind error");
        return 1;
    }
    
    // Listening, maximum client is 5.
    if (listen(sock, 5) == -1) {
        perror("listen error");
        return 1;
    }
}

同理的,调用失败返回-1.

 

在服务器端调用 listen() 函数将Socket置于监听状态后,服务器会等待客户端的连接请求。当有客户端尝试连接时,服务器使用 accept() 函数来接受这个连接。

accept() 函数的原型如下:

#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • sockfd: 这是监听Socket的文件描述符,也就是之前调用 socket()bind()listen() 创建的Socket。这个Socket负责监听连接请求。
  • addr: 这是一个指向 sockaddr 结构体的指针。 当 accept() 函数成功返回时,内核会填充这个结构体,包含连接到服务器的客户端的地址信息(IP地址和端口号)。 和 bind() 函数一样,这里使用通用的 sockaddr 指针,实际使用时需要传入 sockaddr_in*,进行强制类型转换。
  • addrlen: 这是一个指向 socklen_t 类型变量的指针。 在调用 accept() 之前,需要将 addrlen 指向的变量初始化为 addr 指向的结构体的大小 (例如 sizeof(struct sockaddr_in))。accept() 函数成功返回时,内核会更新这个变量的值,指示实际存储在 addr 中的客户端地址信息的长度。

 

accept() 函数会创建一个新的Socket,用于与发起连接的客户端进行通信。这个新的Socket与监听Socket使用相同的协议和地址族。

  • 返回新的Socket文件描述符: accept() 函数的返回值是这个新的连接Socket的文件描述符。服务器需要使用这个新的文件描述符来与特定的客户端进行数据交换。原来的监听Socket仍然用于监听新的连接请求。失败时返回-1
  • 阻塞等待连接:如果当前没有等待处理的连接请求,accept() 函数会阻塞 (block),直到有客户端发起连接。

下面是一个使用accept() 函数的例子:

// server.c
int main() {
    // Initialize the serv_addr structure
    struct sockaddr_in server_addr;
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 绑定到本地回环地址
    server_addr.sin_port = htons(8080); // 绑定到 8080 端口
    memset(server_addr.sin_zero, '\0', sizeof(server_addr.sin_zero));  // 设置填充位,会自动完成
    
    // Create Socket
    int sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock == -1) {
        perror("socket creation error");
        return 1;
    }
    
    // Bind Socket
    if (bind(sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind error");
        return 1;
    }
    
    // Listening, maximum client is 5.
    if (listen(sock, 5) == -1) {
        perror("listen error");
        return 1;
    }
    
    // Accept Client to Connect
    client_addr_len = sizeof(client_addr);
    connect_fd = accept(sock, (struct sockaddr *)&client_addr, &client_addr_len);
    if (connect_fd == -1) {
        perror("accept error");
        return 1;
    }
}

 

客户端创建Socket之后,客户端需要与服务器建立连接。 connect() 函数用于客户端发起与指定服务器的连接请求。

在客户端connect()后,客户端的地址信息才自动分配。其中,IP为host ip,端口为随机分配的端口。

connect() 函数的原型如下:

#include <sys/socket.h>

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
  • sockfd: 这是客户端创建的Socket文件描述符。
  • addr: 这是一个指向 sockaddr 结构体的指针,包含了服务器的地址信息(IP地址和端口号)。通常会使用 sockaddr_in结构体,并将其强制类型转换为 sockaddr *
  • addrlen: 指定了 addr 指向的结构体的大小,以字节为单位。

connect() 函数会尝试与 addr 参数指定的服务器建立连接。触发TCP的三次握手过程,出现错误返回 -1

下面是一个客户端使用connect连接的例子:

// client.c
int main(int argc, char *argv[]) {
    int sock;
    struct sockaddr_in server_addr;

    // Create socket
    sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock == -1) {
        perror("socket creation error");
        return 1;
    }

    // Set server address
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = inet_addr(argv[1]);
    server_addr.sin_port = htons(atoi(argv[2]));
    
    // Connect to Server
    if (connect(sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1)
    {
        perror("connect error");
        return 1;
    }
}

注意:这是客户端才需要的函数,服务器端不需要使用connect()。同理,客户端没有bind(), listen(), accept()的过程。

 

建立Socket连接后,我们需要使用特定的函数来发送和接收数据。根据Socket的类型(TCP或UDP),使用的函数有所不同。

通用的有:read()write() 。它们是底层的系统调用,用于对文件描述符进行基本的输入/输出操作。它们可以用于读取和写入各种类型的文件,包括普通文件、管道、终端以及Socket。

  • 发送数据: 可以使用 write(sockfd, buffer, length)buffer 中的 length 个字节的数据发送到Socket sockfd
  • 接收数据: 可以使用 read(sockfd, buffer, buffer_size) 从Socket sockfd 读取最多 buffer_size 个字节的数据,并存储到 buffer 中。

下面是一个使用 read()write() 操作 Socket的例子:

// client.c
int main(int argc, char *argv[]) {
    int sock;
    struct sockaddr_in server_addr;

    // Create socket
    sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock == -1) {
        perror("socket creation error");
        return 1;
    }

    // Set server address
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = inet_addr(argv[1]);
    server_addr.sin_port = htons(atoi(argv[2]));
    
    // Connect
    if (connect(sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1)
    {
        perror("connect error");
        return 1;
    }

    // 无限循环发送和接收信息,实现持续与服务器沟通
    while(1)
    {
        char send_message[2048] = "\0";

        printf("Client message: ");
        
        // 逐个读入
        while (!strcmp(send_message, "\0")){
            char temp='\0';
            while((temp = getchar()) != '\n' && temp != EOF)
                send_message[strlen(send_message)] = temp;
        }
        
        // 发送数据
        if (write(sock, send_message, strlen(send_message)) == -1)
        {
            perror("write error");
            return 1;
        }
        printf("Send message: %s\n", send_message);

        sleep(1);
        
        char message[2048] = "\0";
        
        // 接收数据
        if (read(sock, message, sizeof(message)) == -1)
        {
            sleep(1);
        }
        printf("Recive message: %s\n", message);
    }

    close(sock);
    return 0;
}

除了通用的read()write()外,根据不同的协议还有不同的函数。

send()recv() 函数(用于面向连接的Socket,如 TCP)

#include <sys/socket.h>

ssize_t send(int sockfd, const void *buf, size_t len, int flags);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
  • 发送数据:buf 中的 len 个字节的数据发送到与 sockfd 关联的Socket连接的另一端。
  • 接收数据: 尝试从与 sockfd 关联的Socket连接的另一端接收最多 len 个字节的数据,并将接收到的数据存储到 buf 中。

下面是一个使用 send()recv()的例子:

// client.c
int main(int argc, char *argv[]) {
    int sock;
    struct sockaddr_in server_addr;

    // Create socket
    sock = socket(AF_INET, SOCK_STREAM, 0);
    if (sock == -1) {
        perror("socket creation error");
        return 1;
    }

    // Set server address
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = inet_addr(argv[1]);
    server_addr.sin_port = htons(atoi(argv[2]));
    
    // Connect
    if (connect(sock, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1)
    {
        perror("connect error");
        return 1;
    }

    // 无限循环发送和接收信息,实现持续与服务器沟通
    while(1)
    {
        char send_message[2048] = "\0";

        printf("Client message: ");
        
        // 逐个读入
        while (!strcmp(send_message, "\0")){
            char temp='\0';
            while((temp = getchar()) != '\n' && temp != EOF)
                send_message[strlen(send_message)] = temp;
        }
        
        // 发送数据
        if (send(sock, send_message, strlen(send_message), 0) == -1)
        {
            perror("send error");
            return 1;
        }
        printf("Send message: %s\n", send_message);

        sleep(1);
        
        char message[2048] = "\0";
        
        // 接收数据
        if (recv(sock, message, sizeof(message), 0) == -1)
        {
            sleep(1);
        }
        printf("Recive message: %s\n", message);
    }

    close(sock);
    return 0;
}

此外,对于无连接的Socket,如 UDP,我们还有其他的输入输出函数,在后续UDP Socket中会提到。

 

当Socket不再需要使用时,我们需要将其关闭以释放系统资源。 关闭Socket主要有两种方式:使用 close() 函数和 shutdown() 函数。

close() 函数是一个通用的系统调用,用于关闭任何文件描述符,包括Socket文件描述符。

#include <unistd.h>

int close(int fd);

对于Socket文件描述符,close() 函数会终止与该Socket相关的连接。关闭文件描述符后,该文件描述符将不再有效,不能再用于任何操作。

  • 对于TCP Socket: close() 会发起正常的TCP四次挥手断开连接过程。 如果还有数据在发送缓冲区中,内核会尝试发送完这些数据再关闭连接。
  • 对于UDP Socket: close() 会立即释放与该Socket相关的资源。

在服务器端,通常需要关闭两个Socket:

  1. 监听Socket (listen_fd): 当服务器不再需要接受新的连接请求时,可以关闭监听Socket。 一旦监听Socket被关闭,就无法再接受新的连接。
  2. 连接Socket (connect_fd): 当与某个客户端的通信结束后,需要关闭与该客户端连接的Socket。 每个通过 accept() 返回的连接Socket都需要单独关闭。

在客户端,通常只需要关闭一个Socket: 客户端在完成与服务器的通信后,关闭自己创建的Socket。

 

shutdown() 函数提供了更精细的控制,允许你只关闭Socket连接的发送方向或接收方向,或者同时关闭两个方向。

#include <sys/socket.h>

int shutdown(int sockfd, int how);
  • sockfd: 要关闭的Socket文件描述符。
  • how: 指定关闭的方式,有以下几个选项:
    • SHUT_RD: 关闭Socket的接收方向。 进程无法再从该Socket接收数据。 接收缓冲区中的数据会被丢弃。
    • SHUT_WR: 关闭Socket的发送方向。 进程无法再通过该Socket发送数据。 发送缓冲区中的数据会被发送出去。
    • SHUT_RDWR: 同时关闭Socket的接收方向和发送方向。 相当于调用 close()

下面是使用close()函数的例子:

int main() {
    int socket_fd;

    // ... (创建 Socket 的代码) ...
    socket_fd = socket(AF_INET, SOCK_STREAM, 0);
    // ...

    // 使用 close() 关闭 Socket
    close(socket_fd);

    return 0;
}

 

在我们整个TCP Socket交互的过程中,时常遇到地址转换的问题。

  • 发送数据时: 当你需要通过网络发送多字节数据类型时,需要将这些数据从主机字节序转换为网络字节序。这确保了接收方能够正确地解释这些数据,即使发送方和接收方的字节序不同。
  • 接收数据后: 当你从网络接收到多字节数据类型时,需要将这些数据从网络字节序转换回主机字节序,以便你的程序能够正确地使用这些数据。

通常需要进行字节序转换的数据类型包括

  • 端口号 (Port Numbers): 端口号是 16 位的整数。使用 htons() 在发送前转换,使用 ntohs() 在接收后转换。
  • IP 地址 (IP Addresses): 对于 IPv4,IP 地址通常表示为 32 位的整数。使用 htonl() 在发送前转换,使用 ntohl() 在接收后转换。

具体的转换函数:

  • 主机到网络字节序:
    • uint32_t htonl(uint32_t hostlong); 将 32 位无符号整数从主机字节序转换为网络字节序。
    • uint16_t htons(uint16_t hostshort); 将 16 位无符号整数从主机字节序转换为网络字节序。
  • 网络到主机字节序:
    • uint32_t ntohl(uint32_t netlong); 将 32 位无符号整数从网络字节序转换为主机字节序。
    • uint16_t ntohs(uint16_t netshort); 将 16 位无符号整数从网络字节序转换为主机字节序。

只要你在发送或接收涉及到端口号IP 地址的数字表示时,就需要考虑使用字节序转换函数。对于其他数据,例如字符串或单字节数据,则不需要进行转换。

功能 函数 作用 适用地址族 备注
主机到网络字节序 htonl() 将 32 位主机字节序整数转换为网络字节序 IPv4 用于转换 IPv4 地址(in_addr_t
htons() 将 16 位主机字节序整数转换为网络字节序 通用 用于转换端口号(in_port_t
网络到主机字节序 ntohl() 将 32 位网络字节序整数转换为主机字节序 IPv4 用于转换接收到的 IPv4 地址
ntohs() 将 16 位网络字节序整数转换为主机字节序 通用 用于转换接收到的端口号

我们有时候会需要打印出人类可读的文本格式进行显示,这时候还需要在IP地址的文本表示(点分十进制或十六进制)二进制表示(网络字节序的整数)之间转换。

  • inet_addr(): 将点分十进制的 IP 地址字符串转换为网络字节序的 32 位整数。
  • inet_aton(): 将点分十进制的 IP 地址字符串转换为网络字节序,并存储在 struct in_addr 结构中。
  • inet_ntoa(): 将 struct in_addr 结构体中的 IPv4 地址转换为点分十进制字符串。

 

下面是一个简单的例子,其中包含了inet_addr(), htons(), inet_ntoa(), ntohs()

// server.c
int main(int argc, char *argv[]) {
    int serv_sock;
    struct sockaddr_in serv_addr, clnt_addr;
    socklen_t clnt_addr_size = sizeof(clnt_addr);

    // Create Socket
    serv_sock = socket(AF_INET, SOCK_STREAM, 0);

    // Initialize the serv_addr structure
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    serv_addr.sin_port = htons(atoi(argv[1]));

    // Bind Socket
    if (bind(serv_sock, (struct sockaddr *) &serv_addr, sizeof(serv_addr)) == -1) {
        perror("bind error");
        return 1;
    }
    
    // Listen Socket
    if (listen(serv_sock, 5) == -1) {
        perror("listen error");
        return 1;
    }
    
    // Accept
    int clnt_sock = accept(serv_sock, (struct sockaddr *) &clnt_addr, &clnt_addr_size);
    if (clnt_sock == -1) {
        perror("accept error");
        sem_post(&client_sem);
        continue;
    }
    
    printf("Accepted connection from %s:%d\n", inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port));
}

 


UDP Sockets

UDP客户端和服务器之间的交互过程大致可以描述为如下步骤和流程图:

客户端:

  1. 创建 UDP Socket: 客户端首先会创建一个 UDP Socket。这个过程与 TCP 类似,都是通过 socket() 系统调用完成,但会指定 SOCK_DGRAM 类型来表明这是一个 UDP Socket。
  2. (可选)绑定本地地址和端口 (绑定地址): 通常情况下,UDP 客户端不需要显式地绑定到一个特定的本地地址和端口。操作系统会在发送第一个数据报时自动分配一个临时的端口号和本地 IP 地址。
    • 如果客户端需要接收服务器的响应,并且希望使用一个固定的端口,或者客户端运行在多网卡主机上需要指定源 IP 地址,那么可以显式地进行绑定。
  3. 发送数据报 (发送数据): 客户端使用 sendto() 系统调用将数据报发送到指定的服务器地址和端口。
  4. 接收服务器响应 (接收数据): 如果服务器需要响应客户端,客户端可以使用 recvfrom() 系统调用来接收服务器发送的数据报。
  5. 关闭 Socket: 当客户端完成通信后,会使用 close() 系统调用关闭 UDP Socket。

 

服务器:

  1. 创建 UDP Socket: 服务器首先会创建一个 UDP Socket,同样指定 SOCK_DGRAM 类型。
  2. 绑定本地地址和端口 (绑定地址): UDP 服务器必须绑定到一个特定的本地地址和端口。客户端会向这个地址和端口发送数据。
  3. 接收客户端数据报 (接收数据): 服务器使用 recvfrom() 系统调用来接收来自客户端的数据报。
  4. 向客户端发送响应 (发送数据): 如果需要,服务器可以使用 sendto() 系统调用向发送数据报的客户端发送响应。
  5. 关闭 Socket : 对于一些简单的服务器,完成任务后可能会关闭 Socket。但对于需要持续服务的服务器,通常会一直运行并监听新的连接。

udp_socket_flow

与 TCP 相比,UDP 最大的特点是它是无连接的,并且不保证可靠传输

  • TCP 在数据传输前需要进行三次握手来建立连接。UDP 没有这个过程。UDP 的设计目标是简单和高效。省略连接建立过程减少了延迟和开销。UDP 通信双方不需要预先建立连接状态,可以直接发送数据。从交互过程来看,就是缺少了listen(), connect()accept()的过程,而是直接发送了数据。
  • TCP 提供了可靠的、有序的、无差错的数据传输保证,通过序号、确认应答、超时重传等机制实现。UDP 没有这些机制。UDP 数据报可能会丢失、重复、乱序到达。

 

UDP 服务器端与 TCP 服务器端的有如下不同之处:

  1. Socket 类型 (Socket Type):
    • TCP: 使用 SOCK_STREAM 创建流式套接字,提供面向连接的、可靠的字节流服务。
    • UDP: 使用 SOCK_DGRAM 创建数据报套接字,提供无连接的、不可靠的数据报服务。
    int servSock;
    
    // Create UDP socket
    if ((servSock = socket(PF_INET, SOCK_DGRAM, 0)) < 0) {
        perror("socket() failed");
        exit(1);
    }
    
  2. 缺少 listen()accept():
    • TCP: TCP 服务器需要调用 listen() 监听连接请求,然后调用 accept() 接受客户端的连接,创建一个新的套接字用于与该客户端通信。
    • UDP: UDP 是无连接的,服务器不需要监听连接,也不需要接受连接。服务器直接使用创建的套接字进行数据接收和发送。
  3. 使用 recvfrom() 接收数据:
    • TCP: TCP 服务器使用 read()recv() 从已建立的连接套接字接收数据。
    • UDP: UDP 服务器使用 recvfrom() 接收数据报。recvfrom() 函数会同时返回接收到的数据以及发送方的地址信息(IP 地址和端口号)。这是因为 UDP 是无连接的,服务器接收到的每个数据报都可能来自不同的客户端。
    int n = recvfrom(servSock, buffer, sizeof(buffer), 0,
                           (struct sockaddr *)&client_addr, &client_len);
    if (n == -1) {
        perror("recvfrom");
        exit(EXIT_FAILURE);
    }
    buffer[n] = '\0'; // Null-terminate the received data
    printf("Received from UDP client: %s\n", buffer);
    
  4. 使用 sendto() 发送数据:
    • TCP: TCP 服务器使用 write()send() 通过已建立的连接套接字发送数据。目标客户端已经通过 accept() 确定。
    • UDP: UDP 服务器使用 sendto() 发送数据报。sendto() 函数需要显式指定接收方的地址信息,因为 UDP 是无连接的,服务器需要知道将数据发送到哪个客户端。
    // 发送响应给客户端
    const char *message = "Hello from UDP server!";
    if (sendto(servSock, message, strlen(message), 0,
               (struct sockaddr *)&client_addr, client_len) == -1) {
        perror("sendto");
        exit(EXIT_FAILURE);
    }
    printf("Response sent to UDP client\n");
    
  5. 客户端地址信息的处理 (Handling Client Address Information):
    • TCP: TCP 服务器在 accept() 成功后,会获得一个与特定客户端连接的新的套接字,后续的通信都通过这个新的套接字进行,客户端的地址信息通常在 accept() 调用中获得。
    • UDP: UDP 服务器接收到的每个数据报都包含了发送方的地址信息。服务器需要保存这个地址信息,以便后续向该客户端发送响应。

 

下面是一个完整的UDP Socket交互过程的服务器端代码:

int main(int argc, char *argv[])
{
    int servSock;
    struct sockaddr_in servAddr, clientAddr;

    // Create UDP socket
    if ((servSock = socket(PF_INET, SOCK_DGRAM, IPPROTO_UDP)) < 0) {
        perror("socket() failed");
        exit(1);
    }

    // Construct local address structure
    memset(&servAddr, 0, sizeof(servAddr));
    servAddr.sin_family = AF_INET;
    servAddr.sin_addr.s_addr = htonl(INADDR_ANY);
    servAddr.sin_port = htons(atoi(argv[1]));

    // Bind the socket to the local address
    if (bind(servSock, (struct sockaddr *) &servAddr, sizeof(servAddr)) < 0) {
        perror("bind() failed");
        exit(1);
    }

    while(1)
    {
        while(1)
        {
            char recvBuffer[2048] = "\0";
            socklen_t clientAddrLen = sizeof(clientAddr);
            memset(&clientAddr, 0, clientAddrLen);

            // Receive message from client
            if (recvfrom(servSock, recvBuffer, sizeof(recvBuffer), 0,
                        (struct sockaddr *) &clientAddr, &clientAddrLen) < 0) {
                perror("recvfrom() failed");
                exit(1);
            }
            printf("Server: Received client address: %s, port: %d\n", inet_ntoa(clientAddr.sin_addr), ntohs(clientAddr.sin_port));
            printf("Server: Received message from client: %s\n", recvBuffer);

            // Send received message to client
            printf("Server: Sending message: ");
            char replyBuffer[2048] = "\0";
            char temp = '\0';
            while ((temp = getchar()) != '\n' && temp != EOF)
                replyBuffer[strlen(replyBuffer)] = temp;

            if (sendto(servSock, replyBuffer, strlen(replyBuffer), 0,
                    (struct sockaddr *) &clientAddr, clientAddrLen) != strlen(replyBuffer)) {
                perror("sendto() failed");
                exit(1);
            }
        }
    }
    
    close(servSock);
    return 0;
}

Socket Options

我们可以为Socket进行一些自定义设置,一般使用如下两个函数:

  • getsockopt(): 用于获取与某个套接字关联的选项的当前值。
  • setsockopt(): 用于设置与某个套接字关联的选项的值。

这两个函数允许我们查询和修改套接字的行为,例如超时设置、地址重用、Nagle 算法的启用与禁用等等。

getsockopt()的原型如下:

#include <sys/socket.h>

int getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen);
  • sockfd: 要操作的套接字的文件描述符。
  • level: 指定选项所在的协议层。常见的有:
    • SOL_SOCKET: 通用套接字选项,与协议无关。
    • IPPROTO_TCP: TCP 协议选项。
    • IPPROTO_IP: IP 协议选项。
  • optname: 要获取的选项名称。例如 SO_REUSEADDRSO_RCVTIMEOSO_ERROR 等。
  • optval: 指向缓冲区的指针,用于存储获取到的选项值。
  • optlen: 指向 socklen_t 类型的指针,调用前需要设置缓冲区 optval 的大小,调用后会被设置为实际获取到的选项值的大小。

 

setsockopt()的函数原型:

#include <sys/socket.h>

int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
  • sockfd: 要操作的套接字的文件描述符。
  • level: 指定选项所在的协议层,与 getsockopt() 相同。
  • optname: 要设置的选项名称。
  • optval: 指向包含要设置的选项值的缓冲区的指针。
  • optlen: 指定 optval 缓冲区的大小。

 

下面是一个使用的例子:

int main() {
    int sockfd;
    int socktype;
    socklen_t optlen = sizeof(socktype);
    int reuse = 1;

    // 创建一个 TCP socket
    if ((sockfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    // 获取套接字类型
    if (getsockopt(sockfd, SOL_SOCKET, SO_TYPE, &socktype, &optlen) == -1) {
        perror("getsockopt");
        close(sockfd);
        exit(EXIT_FAILURE);
    }
    
    // 设置 SO_REUSEADDR 选项,允许端口重用
    if (setsockopt(sockfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) == -1) {
        perror("setsockopt");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    close(sockfd);
    return 0;
}

 

常用 Socket Options 及其 level:

选项名称 Level 数据类型 描述 用途 (Get/Set)
SO_BROADCAST SOL_SOCKET int (非零值启用) 允许在 UDP 套接字上发送广播消息。默认情况下,为了安全,UDP 套接字不允许发送广播。 Set
SO_RCVBUF SOL_SOCKET int 获取或设置套接字的接收缓冲区大小(以字节为单位)。这个缓冲区用于存放接收到的数据,直到应用程序读取。增大它可以提高网络吞吐量,但也可能消耗更多内存。 Get/Set
SO_REUSEADDR SOL_SOCKET int (非零值启用) 允许在 bind() 时绑定到处于 TIME_WAIT 状态的地址和端口。这在服务器快速重启时非常有用,可以避免 "Address already in use" 错误。 Set
SO_SNDBUF SOL_SOCKET int 获取或设置套接字的发送缓冲区大小(以字节为单位)。这个缓冲区用于存放应用程序要发送的数据,直到数据被发送到网络。增大它可以提高网络吞吐量,但也可能消耗更多内存。 Get/Set
SO_TYPE SOL_SOCKET int 只读获取套接字的类型(例如 SOCK_STREAMSOCK_DGRAM)(仅用于 getsockopt())。 Get
IP_MULTICAST_TTL IPPROTO_IP unsigned char 设置组播数据包的生存时间 (Time To Live, TTL)。TTL 值决定了数据包可以经过多少个路由器。 Set
IP_ADD_MEMBERSHIP IPPROTO_IP struct ip_mreq 用于将主机加入到一个组播组。需要指定组播地址和本地接口地址。 Set
IP_DROP_MEMBERSHIP IPPROTO_IP struct ip_mreq 用于将主机从一个组播组中移除。需要指定要离开的组播地址和本地接口地址。 Set

Multiple Recipients

向多个接收者发送消息一般使用广播 (Broadcast) 和组播 (Multicast) 技术。

  • 广播 (Broadcast): 广播是指网络中的一台主机向同一网络中的所有其他主机发送数据的行为。 广播使用特殊的广播地址255.255.255.255,或者当前网段的广播地址。
  • 组播 (Multicast): 组播是指网络中的一台主机向加入特定组播组的其他主机发送数据的行为。

    组播使用特殊的 D 类 IP 地址,范围从 224.0.0.0239.255.255.255

    • 224.0.0.0224.0.0.255 是预留的本地链路组播地址,用于在本地网络段内通信。
    • 224.0.1.0238.255.255.255 是全球范围的组播地址。
    • 239.0.0.0239.255.255.255 是私有范围的组播地址,类似于私有 IP 地址。

    主机需要显式地加入一个组播组才能接收发送到该组的消息。这通过使用 setsockopt() 函数和 IP_ADD_MEMBERSHIP 选项来实现。离开组播组则使用 IP_DROP_MEMBERSHIP 选项。

 

广播 (Broadcast) 和组播 (Multicast)传输过程中均使用 UDP 进行传输,广播一般只覆盖一个局部区域,组播也一般不会跨越整个Internet。

这是一个UDP 广播发送端的例子:

#define PORT 8888
#define BROADCAST_ADDR "255.255.255.255" // 广播地址

int main() {
    int sockfd;
    struct sockaddr_in broadcast_addr;
    int broadcastEnable = 1;
    char *message = "Hello, broadcast message!";

    // 创建 UDP socket
    if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) == -1) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    // 允许发送广播消息
    if (setsockopt(sockfd, SOL_SOCKET, SO_BROADCAST, &broadcastEnable, sizeof(broadcastEnable)) == -1) {
        perror("setsockopt");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    memset(&broadcast_addr, 0, sizeof(broadcast_addr));
    broadcast_addr.sin_family = AF_INET;
    broadcast_addr.sin_addr.s_addr = inet_addr(BROADCAST_ADDR);  // 广播地址
    broadcast_addr.sin_port = htons(PORT);

    // 发送广播消息
    printf("Sending broadcast message...\n");
    if (sendto(sockfd, message, strlen(message), 0,
               (struct sockaddr *)&broadcast_addr, sizeof(broadcast_addr)) == -1) {
        perror("sendto");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    close(sockfd);
    return 0;
}

这是一个UDP 广播接收端的例子:

#define PORT 8888
#define MAX_BUFFER 1024

int main() {
    int sockfd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    char buffer[MAX_BUFFER];

    // 创建 UDP socket
    if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) == -1) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有接口
    server_addr.sin_port = htons(PORT);

    // 绑定地址
    if (bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    printf("Listening for broadcast messages on port %d...\n", PORT);

    // 接收广播消息
    int n = recvfrom(sockfd, buffer, sizeof(buffer), 0,
                       (struct sockaddr *)&client_addr, &client_len);
    if (n == -1) {
        perror("recvfrom");
        close(sockfd);
        exit(EXIT_FAILURE);
    }
    buffer[n] = '\0';
    printf("Received broadcast message from %s:%d: %s\n",
           inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), buffer);

    close(sockfd);
    return 0;
}

这是一个UDP 组播发送端的例子:

#define PORT 8888
#define MULTICAST_ADDR "224.0.1.1" // 一个常见的组播地址

int main() {
    int sockfd;
    struct sockaddr_in multicast_addr;
    char *message = "Hello, multicast message!";
    struct in_addr localInterface;

    // 创建 UDP socket
    if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) == -1) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    memset(&multicast_addr, 0, sizeof(multicast_addr));
    multicast_addr.sin_family = AF_INET;
    multicast_addr.sin_addr.s_addr = inet_addr(MULTICAST_ADDR);
    multicast_addr.sin_port = htons(PORT);

    // 设置 TTL (可选,控制组播消息的传播范围)
    u_char ttl = 1;
    if (setsockopt(sockfd, IPPROTO_IP, IP_MULTICAST_TTL, (char *)&ttl, sizeof(ttl)) < 0) {
        perror("setsockopt - IP_MULTICAST_TTL");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    // 发送组播消息
    printf("Sending multicast message to %s:%d...\n", MULTICAST_ADDR, PORT);
    if (sendto(sockfd, message, strlen(message), 0,
               (struct sockaddr *)&multicast_addr, sizeof(multicast_addr)) == -1) {
        perror("sendto");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    close(sockfd);
    return 0;
}

这是UDP 组播接收端的例子:

#define PORT 8888
#define MULTICAST_ADDR "224.0.1.1"
#define MAX_BUFFER 1024

int main() {
    int sockfd;
    struct sockaddr_in server_addr, client_addr;
    struct ip_mreq group;
    socklen_t client_len = sizeof(client_addr);
    char buffer[MAX_BUFFER];
    int reuse = 1;

    // 创建 UDP socket
    if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0)) == -1) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);

    // 绑定地址
    if (bind(sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    // 加入组播组
    group.imr_multiaddr.s_addr = inet_addr(MULTICAST_ADDR);
    group.imr_interface.s_addr = INADDR_ANY; // 可以指定特定的接口地址
    if (setsockopt(sockfd, IPPROTO_IP, IP_ADD_MEMBERSHIP, (char *)&group, sizeof(group)) < 0) {
        perror("setsockopt - IP_ADD_MEMBERSHIP");
        close(sockfd);
        exit(EXIT_FAILURE);
    }

    printf("Listening for multicast messages on %s:%d...\n", MULTICAST_ADDR, PORT);

    // 接收组播消息
    int n = recvfrom(sockfd, buffer, sizeof(buffer), 0,
                       (struct sockaddr *)&client_addr, &client_len);
    if (n == -1) {
        perror("recvfrom");
        close(sockfd);
        exit(EXIT_FAILURE);
    }
    buffer[n] = '\0';
    printf("Received multicast message from %s:%d: %s\n",
           inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port), buffer);
    
    close(sockfd);
    return 0;
}


Multi-process Server

多进程服务器是一种处理并发连接的常见方法,它通过创建多个进程来同时处理多个客户端的请求。

多进程服务器的基本工作流程如下:

  1. 主进程 (Main Process):
    • 创建监听 Socket: 主进程首先创建一个监听 Socket,用于监听客户端的连接请求。
    • 绑定地址和端口: 主进程将监听 Socket 绑定到一个特定的 IP 地址和端口号。
    • 监听连接: 主进程开始监听该端口上的连接请求。
    • 接受连接: 当有客户端发起连接请求时,主进程调用 accept() 函数接受连接,并创建一个新的连接 Socket。
    • 创建子进程: 主进程使用 fork() 函数创建一个新的子进程。
    • 分发连接: 主进程将连接 Socket 的文件描述符传递给子进程。
    • 继续监听: 主进程继续监听新的连接请求。
  2. 子进程 (Child Process):
    • 处理请求: 子进程接收到连接 Socket 的文件描述符后,开始与客户端进行数据交换。
    • 处理数据: 子进程读取客户端发送的数据,进行处理,并将结果发送回客户端。
    • 关闭连接: 子进程处理完客户端的请求后,关闭连接 Socket。
    • 结束进程: 子进程完成任务后,会结束自身的进程。

multiprocess_flow

 

当多进程服务器accept一个连接后,会调用fork()函数创建一个新的进程去处理这次对话,而其本身继续监听,重复这个过程。

fork() 是一个 Unix/Linux 系统调用,用于创建一个新的进程。

  1. 当父进程调用 fork() 时,操作系统会创建一个几乎与父进程完全相同的副本。
  2. fork() 会创建一个新的进程地址空间,这个地址空间是父进程地址空间的逻辑副本。这意味着子进程拥有父进程用户空间内存的一份拷贝,包括:
    • 代码段 (Text Segment): 包含程序的机器指令。
    • 数据段 (Data Segment): 包含已初始化的全局变量和静态变量。
    • 堆 (Heap): 动态分配的内存区域。
    • 栈 (Stack): 局部变量、函数调用信息等。
  3. 子进程也会继承父进程的大部分资源,例如打开的文件描述符。虽然文件描述符被复制,但是它们指向的是同一个文件表项,因此对文件偏移量的修改会影响到父子进程
  4. fork() 调用会返回两次:一次在父进程中,一次在子进程中。

 

fork() 的返回值:

  • 在父进程中: fork() 返回新创建的子进程的进程 ID (PID),它是一个正整数。
  • 在子进程中: fork() 返回 0
  • 如果发生错误: fork() 返回 -1,并设置全局变量 errno 来指示错误类型(例如,无法创建新进程)。

 

下面是一个如何使用fork()的例子:

int listenfd = socket(AF_INET, SOCK_STREAM, 0);

// bind, listen...

while (1) {
    int connfd = accept(listenfd, (struct sockaddr*)NULL, NULL);
    if (connfd == -1) {
        perror("accept error");
        continue;
    }
    pid_t pid = fork();
    if (pid == 0) { // 子进程
        close(listenfd); // 子进程不需要监听 Socket
        // 处理客户端请求
        handle_client(connfd);
        close(connfd); // 关闭连接 Socket
        exit(0); // 子进程结束
    } else if (pid > 0) { // 父进程
        close(connfd); // 父进程不需要连接 Socket
    } else {
        perror("fork error");
    }
}

void handle_client(int connfd) {
    char buffer[MAXLINE];
    int n;
    while ((n = read(connfd, buffer, MAXLINE)) > 0) {
        // 处理客户端发送的数据
        // ...
        write(connfd, buffer, n); // 将数据发送回客户端
    }
    if (n == 0) {
        printf("Client closed connection\n");
    } else if (n < 0) {
        perror("read error");
    }
}

此外,我们可以使用getpid()函数去获取当前进程的ID,而使用getppid()函数可以去获取当前进程的父进程的ID

 

但在多进程服务器中,很容易出现常见的两种特殊进程状态:孤儿进程 (Orphan Process) 和僵尸进程 (Zombie Process)

  • 孤儿进程 (Orphan Process):孤儿进程是指它的父进程已经终止的进程。当一个父进程在子进程还在运行时意外或正常终止时,子进程就变成了孤儿进程。
  • 僵尸进程 (Zombie Process)一个子进程已经结束运行,但它的父进程还没有调用 wait()waitpid() 函数来获取子进程的退出状态信息,那么这个子进程就变成了僵尸进程。如果系统中存在大量的僵尸进程,会耗尽系统的进程表,导致无法创建新的进程。

 

操作系统会自动处理孤儿进程。当一个进程变成孤儿进程时,它会被 init 进程 (进程 ID 为 1) 所收养。init 进程是所有进程的祖先,它负责回收这些孤儿进程的资源,防止资源泄漏。

下面是一个孤儿进程例子:

int main() {
    pid_t pid;

    pid = fork();

    if (pid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    } else if (pid == 0) {
        // 子进程
        printf("Child process (PID: %d), parent PID: %d\n", getpid(), getppid());
        printf("Child process is running...\n");
        sleep(10); // 子进程运行一段时间
        printf("Child process finished.\n");
    } else {
        // 父进程
        printf("Parent process (PID: %d), child PID: %d\n", getpid(), pid);
        printf("Parent process is exiting...\n");
        exit(EXIT_SUCCESS); // 父进程先退出
    }
    return 0;
}

编译并运行该程序,然后在子进程还在休眠时,使用 ps aux | grep <子进程PID> 命令查看子进程的父进程 ID (PPID)。会发现子进程的 PPID 变成了 1,即 init 进程。

 

对于僵尸进程,父进程应该及时调用 wait()waitpid() 函数来回收子进程的资源,避免产生僵尸进程。

  • wait() 函数:
    • wait() 函数会阻塞父进程,直到有子进程结束。
    • wait() 函数会返回结束子进程的 PID,并将子进程的退出状态信息存储在指定的变量中。
    • 如果父进程有多个子进程,wait() 函数只会返回一个结束子进程的信息。
    • wait() 函数的声明如下:
      #include <sys/wait.h>
      pid_t wait(int *status);
      

      参数status用来保存被收集进程退出时的一些状态。有两个常用的宏 (macro) 去处理这些状态:

      WIFEXITED(status): 判断子进程是否正常退出,若正常则返回非0值;

      WEXITSTATUS(status): 提取子进程的返回值。

  • waitpid() 函数:
    • waitpid() 函数可以指定等待的子进程的 PID,并且可以选择是否阻塞父进程。
    • waitpid() 函数会返回结束子进程的 PID,并将子进程的退出状态信息存储在指定的变量中。
    • waitpid() 函数的声明如下:
      #include <sys/wait.h>
      pid_t waitpid(pid_t pid, int *status, int options);
      
      • pid: 要等待的子进程的 PID。
        • pid > 0: 等待 PID 为 pid 的子进程。
        • pid == 0: 等待同一个进程组中的任何子进程。
        • pid == -1: 等待任何子进程。
        • pid < -1: 等待进程组 ID 为 abs(pid) 的任何子进程。
      • status: 用于存储子进程退出状态信息的变量。
      • options: 可选参数,例如 WNOHANG (非阻塞等待) 或 WUNTRACED / 0 (等待被停止的子进程)。

下面是父进程使用 wait() 回收子进程的例子:

int main() {
    pid_t pid;

    pid = fork();

    if (pid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    } else if (pid == 0) {
        // 子进程
        printf("Child process (PID: %d) is exiting...\n", getpid());
        exit(0);
    } else {
        // 父进程
        printf("Parent process (PID: %d), child PID: %d\n", getpid(), pid);
        printf("Parent process is waiting for the child to finish...\n");
        wait(NULL); // 等待子进程结束
        printf("Parent process finished waiting.\n");
    }

    return 0;
}

下面是父进程使用 waitpid() 回收子进程的例子:

int main() {
    pid_t pid;

    pid = fork();

    if (pid == -1) {
        perror("fork");
        exit(EXIT_FAILURE);
    } else if (pid == 0) {
        // 子进程
        printf("Child process (PID: %d) is exiting...\n", getpid());
        exit(0);
    } else {
        // 父进程
        printf("Parent process (PID: %d), child PID: %d\n", getpid(), pid);
        printf("Parent process is waiting for child with PID %d to finish...\n", pid);
        waitpid(pid, NULL, WNOHANG); // 非阻塞等待
        printf("Parent process finished waiting for child %d.\n", pid);
    }

    return 0;
}

我们将fork()waitpid()整合在一起,就可以得到多进程服务器的基本工作流程

  1. 创建监听套接字: 服务器进程首先创建一个用于监听客户端连接请求的套接字。
  2. 绑定地址和端口: 将套接字绑定到服务器的 IP 地址和端口号。
  3. 开始监听: 服务器开始监听客户端的连接请求。
  4. 进入循环,接受连接: 服务器进入一个无限循环,等待客户端的连接请求。当有新的连接请求到达时,服务器调用 accept() 接受连接,得到一个新的已连接套接字。
  5. 创建子进程处理连接: 对于每一个新的客户端连接,父进程调用 fork() 创建一个子进程。
  6. 子进程处理客户端请求:
    • 子进程会关闭监听套接字(因为它不需要监听新的连接)。
    • 子进程使用已连接套接字与客户端进行通信(接收数据、处理数据、发送响应)。
    • 处理完客户端请求后,子进程会关闭已连接套接字并终止。
  7. 父进程继续监听,并回收子进程:
    • 父进程会关闭已连接套接字(因为连接由子进程处理)。
    • 父进程继续监听新的连接请求。
    • 父进程需要调用 waitpid() 来等待已终止的子进程,并回收其资源,防止僵尸进程的产生。

下面是一个整合的例子:

int main(int argc, char *argv[]) {
    int serv_sock, clnt_sock;
    int status;
    struct sockaddr_in serv_addr, clnt_addr;
    socklen_t clnt_addr_size;
    
    // Create Socket
    serv_sock = socket(AF_INET, SOCK_STREAM, 0);
    if (serv_sock == -1) {
        perror("socket creation error");
        return 1;
    }

    // Initialize the serv_addr structure
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    serv_addr.sin_port = htons(atoi(argv[1]));

    // Bind Socket
    if (bind(serv_sock, (struct sockaddr *) &serv_addr, sizeof(serv_addr)) == -1)
    {
        perror("bind error");
        return 1;
    }
    
    // Listen Socket
    if (listen(serv_sock, 5) == -1)
    {
        perror("listen error");
        return 1;
    }

    while (1){
        // Accept Connection
        clnt_addr_size = sizeof(clnt_addr);
        clnt_sock = accept(serv_sock, (struct sockaddr *) &clnt_addr, &clnt_addr_size);
        if (clnt_sock == -1)
        {
            perror("accept error");
            return 1;
        }
        printf("Client %s:%d connected. Waiting for client message...\n", inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port));

        // Fork
        pid_t child_pid = fork();
        if (child_pid == -1)
        {
            perror("fork error");
            return 1;
        }

        if (child_pid == 0)  // Child process
        {
            close(serv_sock);  // Close listen_socket
            while (1)
            {
                // Recive Message
                char recive_message[2048] = "\0";
                while (read(clnt_sock, recive_message, sizeof(recive_message)) == -1)
                {
                    sleep(1);
                }
                printf("Recive message from %s:%d: %s\n", inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port), recive_message);
                
                // Send Message
                char message[2048] = "\0";
                while (!strcmp(message, "\0")){
                    char temp='\0';
                    while((temp = getchar()) != '\n' && temp != EOF)
                        message[strlen(message)] = temp;
                }
                
                if (write(clnt_sock, message, strlen(message)) == -1)
                {
                    perror("write error");
                    return 1;
                }
            }
            
            // Close Sockets
            close(clnt_sock);
            printf("Client %s:%d disconnected.\n\n", inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port));
            exit(flag);
        }
        else  // Parent process
        {
            close(clnt_sock);    // Close client socket
            waitpid(-1, &status, WNOHANG);
        }
    }
    
    close(serv_sock);
    printf("Server closed.\n");
    return 0;
}

因为子进程会继承父进程的所有打开的文件描述符。当父进程调用 fork() 创建子进程时,子进程会获得父进程打开的套接字(也是一种文件描述符)的副本。

不关闭不必要的Socket的话:

  • 资源泄漏: 文件描述符是有限的资源。如果不及时关闭不再使用的套接字,可能会耗尽系统的文件描述符资源,导致新的连接无法建立。
  • 连接保持存活: 只要有一个进程持有套接字的引用,连接就会保持打开状态。如果父进程不关闭已连接套接字,即使子进程处理完毕,连接也可能不会立即关闭。

在代码中,我们使用了:

  • 在父进程中: close(clnt_sock); // 关闭父进程中已连接的套接字
  • 在子进程中: close(serv_sock); // 关闭子进程中监听的套接字

 

除了close()函数外,我们之前也提到过可以使用shutdown()函数

子进程不需要监听新的连接,因此可以关闭监听套接字。使用 shutdown() 可以这样做:

// 在子进程中
close(listen_sockfd); // 之前的方式

// 使用 shutdown() 的方式
shutdown(listen_sockfd, SHUT_RDWR);

SHUT_RDWR表示同时关闭套接字的方向。

父进程在创建子进程来处理客户端连接后,可以关闭与该客户端的连接套接字。使用 shutdown() 可以这样做:

// 在父进程中
close(client_sockfd); // 之前的方式

// 使用 shutdown() 的方式
shutdown(client_sockfd, SHUT_RDWR);

shutdown()函数关闭的是套接字连接的读写方向,而不是文件描述符本身。在多进程服务器的典型场景中,通常使用 close()

image-20241228164128904

 

除了整合fork()waitpid()外,使用信号处理函数处理 SIGCHLD 信号是一种更加常用且高效的方法。当子进程终止时,操作系统会向父进程发送 SIGCHLD 信号。父进程可以注册一个信号处理函数来捕获这个信号,并在信号处理函数中调用 waitpid() 回收子进程。

下面是使用信号处理的示例:

#define PORT 8888
#define BACKLOG 10
#define MAX_BUFFER 1024

void handle_client(int client_sockfd) {
    char buffer[MAX_BUFFER];
    ssize_t bytes_received;

    // 接收客户端数据
    if ((bytes_received = recv(client_sockfd, buffer, sizeof(buffer), 0)) > 0) {
        buffer[bytes_received] = '\0';
        printf("Child process %d received: %s from client %d\n", getpid(), buffer, client_sockfd);
        
        send(client_sockfd, "Message received!", strlen("Message received!"), 0);
    } else if (bytes_received == 0) {
        printf("Child process %d: Client %d disconnected\n", getpid(), client_sockfd);
    } else {
        perror("recv");
    }
}

void sigchld_handler(int s) {
    // 回收所有已结束的子进程
    while (waitpid(-1, NULL, WNOHANG) > 0);
}

int main() {
    int listen_sockfd, client_sockfd;
    struct sockaddr_in server_addr, client_addr;
    socklen_t client_len = sizeof(client_addr);
    pid_t child_pid;
    struct sigaction sa;

    // 创建Socket
    if ((listen_sockfd = socket(AF_INET, SOCK_STREAM, 0)) == -1) {
        perror("socket");
        exit(EXIT_FAILURE);
    }

    // 设置地址
    memset(&server_addr, 0, sizeof(server_addr));
    server_addr.sin_family = AF_INET;
    server_addr.sin_addr.s_addr = INADDR_ANY;
    server_addr.sin_port = htons(PORT);

    // 绑定地址
    if (bind(listen_sockfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) == -1) {
        perror("bind");
        close(listen_sockfd);
        exit(EXIT_FAILURE);
    }

    // 监听
    if (listen(listen_sockfd, BACKLOG) == -1) {
        perror("listen");
        close(listen_sockfd);
        exit(EXIT_FAILURE);
    }

    printf("Server listening on port %d\n", PORT);

    // 设置 SIGCHLD 信号处理函数
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;
    if (sigaction(SIGCHLD, &sa, NULL) == -1) {
        perror("sigaction");
        close(listen_sockfd);
        exit(EXIT_FAILURE);
    }

    while (1) {
        if ((client_sockfd = accept(listen_sockfd, (struct sockaddr *)&client_addr, &client_len)) == -1) {
            perror("accept");
            continue;
        }

        printf("Parent: Got connection from %s:%d\n", inet_ntoa(client_addr.sin_addr), ntohs(client_addr.sin_port));

        if ((child_pid = fork()) == -1) {
            perror("fork");
            close(client_sockfd);
            continue;
        } else if (child_pid == 0) {
            // 子进程
            close(listen_sockfd); // 子进程关闭监听套接字
            handle_client(client_sockfd);
            close(client_sockfd);
            exit(EXIT_SUCCESS);
        } else {
            // 父进程
            close(client_sockfd); // 父进程关闭已连接套接字
        }
    }

    close(listen_sockfd);
    return 0;
}

 


Multi-thread Server

多线程服务器是另一种处理并发连接的常见方法,它通过创建多个线程来同时处理多个客户端的请求。

多线程服务器的基本工作流程如下:

  1. 主线程 (Main Thread):
    • 创建监听 Socket: 主线程首先创建一个监听 Socket,用于监听客户端的连接请求。
    • 绑定地址和端口: 主线程将监听 Socket 绑定到一个特定的 IP 地址和端口号。
    • 监听连接: 主线程开始监听该端口上的连接请求。
    • 接受连接: 当有客户端发起连接请求时,主线程调用 accept() 函数接受连接,并创建一个新的连接 Socket。
    • 创建子线程: 主线程使用 pthread_create() 函数创建一个新的子线程。
    • 分发连接: 主线程将连接 Socket 的文件描述符传递给子线程。
    • 继续监听: 主线程继续监听新的连接请求。
  2. 子线程 (Child Thread):
    • 处理请求: 子线程接收到连接 Socket 的文件描述符后,开始与客户端进行数据交换。
    • 处理数据: 子线程读取客户端发送的数据,进行处理,并将结果发送回客户端。
    • 关闭连接: 子线程处理完客户端的请求后,关闭连接 Socket。
    • 结束线程: 子线程完成任务后,会结束自身的线程。

multithread_flow

与多进程服务器不同的是,我们使用了线程。它们的区别如下:

  • 进程是资源分配的基本单位,线程是 CPU 调度的基本单位。
  • 进程拥有独立的资源,线程共享进程的资源。
  • 进程间通信需要 IPC,线程间通信可以直接共享内存。
  • 进程的创建、终止和上下文切换开销比线程大。

image-20241228165710033

我们可以通过pthread_create()创建新线程,用来处理一个用户的对话。

它的声明如下:

#include <pthread.h>

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                   void *(*start_routine) (void *), void *arg);
  • thread: 指向 pthread_t 类型变量的指针,用于存储新创建线程的 ID。
  • attr: 指向线程属性对象的指针,可以设置为 NULL 使用默认属性。
  • start_routine: 指向线程执行函数的指针。该函数必须接收一个 void * 类型的参数,并返回一个 void * 类型的值。
  • arg: 传递给 start_routine 函数的参数。

这是一个简单例子:

void *thread_function(void *arg) {
    int thread_id = *(int *)arg;
    printf("Hello from thread %d!\n", thread_id);
    pthread_exit(NULL); // 线程退出
}

int main() {
    pthread_t thread1, thread2;
    int id1 = 1, id2 = 2;

    pthread_create(&thread1, NULL, thread_function, &id1);
    pthread_create(&thread2, NULL, thread_function, &id2);

    // ... 后续操作

    return 0;
}

和进程一样的,当一个线程结束时,它所占用的资源并不会立即被系统回收。为了完全回收线程的资源,需要主线程调用 pthread_join() 函数回收资源,并且获取线程的返回值。

它的声明:

#include <pthread.h>

int pthread_join(pthread_t thread, void **retval);
  • thread: 要等待结束的线程的 ID。
  • retval: 指向指针的指针,用于接收线程的返回值(如果线程通过 pthread_exit() 返回了值)。可以设置为 NULL 如果不需要接收返回值。

示例:

pthread_join(thread1, NULL);
pthread_join(thread2, NULL);

当调用pthread_join()时,线程会阻塞调用它的主线程,直到被等待的线程结束。但考虑到主线程通常会不断地接受新的连接请求,并创建新的子线程来处理这些请求。如果主线程每次都使用 pthread_join() 等待子线程结束,那么主线程就会被阻塞,无法继续接受新的连接请求。

为了解决这个问题,通常会使用pthread_detach()函数将子线程设置为 detached 状态,这样子线程结束后会自动释放资源,无需主线程等待。但是,主线程无法获取到子线程的返回值

它的原型是:

#include <pthread.h>
int pthread_detach(pthread_t thread);
  • thread: 要设置为 detached 状态的线程的 ID (pthread_t 类型)。

当一个线程被设置为 detached 状态后,一旦该线程结束运行,系统会自动回收该线程所占用的资源。被设置为 detached 状态的线程,不需要其他线程调用 pthread_join() 函数来回收其资源。

pthread_detach()pthread_join() 的区别如下

特性 pthread_join() pthread_detach()
作用 等待线程结束,回收资源,获取返回值 将线程设置为 detached 状态,自动回收资源,无法获取返回值
阻塞 会阻塞调用线程 不会阻塞调用线程
资源回收 必须调用才能回收资源 线程结束后自动回收资源
返回值 可以获取线程的返回值 无法获取线程的返回值
使用场景 需要等待线程结束,需要获取返回值,需要同步线程 不需要等待线程结束,不需要获取返回值,后台服务线程

下面是一个使用pthread_detach()的例子:

void *thread_function(void *arg) {
    int thread_id = *(int *)arg;
    printf("Hello from thread %d!\n", thread_id);
    pthread_exit(NULL); // 线程退出
}

int main() {
    pthread_t thread1, thread2;
    int id1 = 1, id2 = 2;

    pthread_create(&thread1, NULL, thread_function, &id1);
    pthread_create(&thread2, NULL, thread_function, &id2);

    if (pthread_detach(thread1) != 0) {
        perror("pthread_detach error");
        return 1;
    }
    
    if (pthread_detach(thread2) != 0) {
        perror("pthread_detach error");
        return 1;
    }

    return 0;
}

 

由于线程共享进程的内存,访问共享资源时需要进行同步,避免出现竞争条件。互斥锁 (mutex) 是最常用的同步机制之一。

下面是一些 mutex 在多线程中使用的函数声明:

#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
int pthread_mutex_destroy(pthread_mutex_t *mutex);

对于全局或静态的互斥锁,可以使用宏 PTHREAD_MUTEX_INITIALIZER 进行静态初始化:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

pthread_mutex_lock() 尝试获取互斥锁。如果互斥锁当前未被任何线程持有,调用线程将成功获取锁并继续执行。如果互斥锁已被其他线程持有,调用线程将被阻塞(进入等待状态),直到锁被释放。

pthread_mutex_unlock(): 尝试解锁互斥锁。

pthread_mutex_destroy(): 销毁互斥锁变量。

下面是一个使用互斥锁同步的示例:

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

pthread_mutex_t count_mutex = PTHREAD_MUTEX_INITIALIZER;
int shared_count = 0;

void *increment_count(void *arg) {
    for (int i = 0; i < 100000; i++) {
        pthread_mutex_lock(&count_mutex); // 加锁,确保对 shared_count 的独占访问
        shared_count++;
        pthread_mutex_unlock(&count_mutex); // 解锁
    }
    pthread_exit(NULL);
}

int main() {
    pthread_t thread1, thread2;

    pthread_create(&thread1, NULL, increment_count, NULL);
    pthread_create(&thread2, NULL, increment_count, NULL);

    pthread_join(thread1, NULL);
    pthread_join(thread2, NULL);

    printf("Shared count: %d\n", shared_count);

    pthread_mutex_destroy(&count_mutex);
    return 0;
}

我们使用互斥锁的原则是: 明确需要保护的临界区: 尽量使用较小的互斥锁,只保护真正需要保护的临界区,避免过度锁定,降低并发性能。

 

此外,信号量 (Semaphore)是一种更通用的同步机制,用于控制对有限数量资源的访问在线程间发送信号。信号量维护一个整数值(称为信号量的值),该值表示可用资源的数量。

常用的信号量函数声明如下:

#include <semaphore.h>

int sem_init(sem_t *sem, int pshared, unsigned int value);
int sem_wait(sem_t *sem);
int sem_post(sem_t *sem);
int sem_destroy(sem_t *sem);
  1. 初始化信号量:sem_init()
  2. 等待信号量(减值):sem_wait() : 尝试获取信号量。
    • 如果信号量的值大于 0,则原子地将信号量的值减 1,调用线程继续执行。
    • 如果信号量的值为 0,则调用线程将被阻塞,直到信号量的值大于 0。
  3. 发布信号量(增值):sem_post(): 将信号量的值加 1。
  4. 销毁信号量:sem_destroy()

下面是一个信号量的使用示例:

这是一个生产者-消费者问题

  • 生产者: 生产者线程生成数据,并将数据放入缓冲区。
  • 消费者: 消费者线程从缓冲区获取数据并进行消费。
  • 缓冲区: 缓冲区是一个共享资源,用于存储生产者生成的数据。
  • 同步: 为了避免数据竞争和缓冲区溢出/下溢,需要使用同步机制来协调生产者和消费者线程的访问。
#define BUFFER_SIZE 5
int buffer[BUFFER_SIZE];
int in = 0;
int out = 0;

sem_t empty; // 记录缓冲区空闲位置的数量
sem_t full;  // 记录缓冲区已占用位置的数量
pthread_mutex_t mutex; // 保护缓冲区的互斥锁

void *producer(void *arg) {
    for (int i = 0; i < 10; i++) {
        sem_wait(&empty); // 等待空闲位置
        pthread_mutex_lock(&mutex);
        buffer[in] = i;
        printf("Produced: %d\n", buffer[in]);
        in = (in + 1) % BUFFER_SIZE;
        pthread_mutex_unlock(&mutex);
        sem_post(&full); // 通知消费者有数据了
        sleep(1);
    }
    return NULL;
}

void *consumer(void *arg) {
    for (int i = 0; i < 10; i++) {
        sem_wait(&full); // 等待数据
        pthread_mutex_lock(&mutex);
        printf("Consumed: %d\n", buffer[out]);
        out = (out + 1) % BUFFER_SIZE;
        pthread_mutex_unlock(&mutex);
        sem_post(&empty); // 通知生产者有空闲位置了
        sleep(2);
    }
    return NULL;
}

int main() {
    pthread_t producer_thread, consumer_thread;

    sem_init(&empty, 0, BUFFER_SIZE); // 初始时缓冲区全空
    sem_init(&full, 0, 0);           // 初始时缓冲区无数据
    pthread_mutex_init(&mutex, NULL);

    pthread_create(&producer_thread, NULL, producer, NULL);
    pthread_create(&consumer_thread, NULL, consumer, NULL);

    pthread_join(producer_thread, NULL);
    pthread_join(consumer_thread, NULL);

    sem_destroy(&empty);
    sem_destroy(&full);
    pthread_mutex_destroy(&mutex);

    return 0;
}

这里使用了互斥锁保护缓冲区,使用信号量控制生产和消费的速度。

对比两者:

特性 互斥锁 (Mutex) 信号量 (Semaphore)
核心概念 提供独占访问,确保同一时刻只有一个线程访问共享资源。 控制对资源的并发访问数量或用于线程间信号传递。
可以被认为是二进制信号量(0 或 1)。 可以是任意非负整数。
加锁/等待 lock 操作,如果锁已被持有则阻塞。 wait (或 sem_wait) 操作,如果值小于等于 0 则阻塞。
解锁/释放 unlock 操作,通常由持有锁的线程释放。 post (或 sem_post) 操作,增加信号量的值。
用途 保护共享资源,实现互斥访问。 限制资源访问数量,实现线程同步和通信。
所有权 有所有权的概念,只有持有锁的线程才能解锁。 没有严格的所有权概念,任何线程都可以 post 信号量。
  • 互斥锁: 更专注于提供对共享资源的独占访问,常用于保护临界区。
  • 信号量: 更通用,可以用于控制对多个资源的访问,或者作为线程间通信的信号。

 

下面是一个使用完整的多线程服务器,整合了互斥锁发送数据和接收数据:

// Synchronization variables
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t client_mutex = PTHREAD_MUTEX_INITIALIZER;

// Client thread function
void* client_thread(void* arg) {
    int* sock_pointer = (int *)arg;
    int clnt_sock = *sock_pointer;

    while (1) {
        // Receive message
        char receive_message[1024];
        ssize_t n = read(clnt_sock, receive_message, sizeof(receive_message) - 1);
        printf("Receive message: %s\n", receive_message);
        pthread_mutex_lock(&mutex);
        
        // Send message
        char send_message[1024];
        printf("Send message: ");
        scanf("%s", send_message);
        
        if (write(clnt_sock, send_message, strlen(send_message)) == -1) {
            perror("write error");
            pthread_mutex_unlock(&mutex);
            break;
        }
        pthread_mutex_unlock(&mutex);
    }
    printf("Client disconnected\n");
    close(clnt_sock);
    return NULL;
}

int main(int argc, char *argv[]) {
    int serv_sock;
    struct sockaddr_in serv_addr, clnt_addr;
    socklen_t clnt_addr_size;
    pthread_t thread_id;

    // Create Socket
    serv_sock = socket(AF_INET, SOCK_STREAM, 0);
    if (serv_sock == -1) {
        perror("socket creation error");
        return 1;
    }

    // Initialize the serv_addr structure
    memset(&serv_addr, 0, sizeof(serv_addr));
    serv_addr.sin_family = AF_INET;
    serv_addr.sin_addr.s_addr = inet_addr("127.0.0.1");
    serv_addr.sin_port = htons(atoi(argv[1]));

    // Bind Socket
    if (bind(serv_sock, (struct sockaddr *) &serv_addr, sizeof(serv_addr)) == -1) {
        perror("bind error");
        return 1;
    }
    
    // Listen Socket
    if (listen(serv_sock, 5) == -1) {
        perror("listen error");
        return 1;
    }

    while (1) {
        clnt_addr_size = sizeof(clnt_addr);
        int clnt_sock = accept(serv_sock, (struct sockaddr *) &clnt_addr, &clnt_addr_size);
        if (clnt_sock == -1) {
            perror("accept error");
            continue;
        }

        printf("Clint connected %s:%d\n", inet_ntoa(clnt_addr.sin_addr), ntohs(clnt_addr.sin_port));

        int* client_args = malloc(sizeof(int));
        if (client_args == NULL) {
            perror("malloc error");
            close(clnt_sock);
            continue;
        }

        *client_args = clnt_sock;  // 传递clinet socket过去,因为只能使用void *格式

        if (pthread_create(&thread_id, NULL, client_thread, (void*)client_args) != 0) {
            perror("pthread_create error");
            free(client_args);
            close(clnt_sock);
            continue;
        }

        pthread_detach(thread_id);
    }

    close(serv_sock);
    return 0;
}

Multiplexing

多路复用 (Multiplexing) 是一种允许单个进程或线程处理多个并发连接的技术。与为每个连接创建一个新的进程或线程不同,多路复用通过轮询事件通知的方式,高效地管理多个套接字上的 I/O 事件。

多路复用的核心在于用一个或少量的进程/线程来监视多个文件描述符(包括Socket)的状态。当某个文件描述符准备好进行 I/O 操作(例如,有数据可读,可以发送数据等)时,操作系统会通知该进程/线程,然后该进程/线程就可以对相应的套接字进行操作。

image-20241228174245071

select() 系统调用允许程序监视多个文件描述符,等待其中一个或多个文件描述符变为“就绪”状态(可读、可写或有异常发生)。

主要步骤:

  1. 创建文件描述符集合 (fd_set): 使用 fd_set 结构来存储需要监视的文件描述符。fd_set 可以包含多个文件描述符。
  2. 设置要监视的事件类型: 可以设置要监视的读事件、写事件和异常事件。
  3. 调用 select() 函数: select() 函数会阻塞,直到有一个或多个被监视的文件描述符准备就绪,或者超时。
  4. 检查哪些文件描述符就绪: select() 返回后,需要检查 fd_set 中哪些文件描述符处于就绪状态。
  5. 处理就绪的文件描述符: 对就绪的文件描述符进行相应的 I/O 操作。

相关函数和数据结构:

  • fd_set 数据类型: 一个位数组,用于表示一组文件描述符。
  • FD_ZERO(fd_set *set) 宏: 清空 fd_set 集合。
  • FD_SET(int fd, fd_set *set) 宏: 将文件描述符 fd 添加到 fd_set 集合中。
  • FD_CLR(int fd, fd_set *set) 宏:fd_set 集合中移除文件描述符 fd
  • FD_ISSET(int fd, fd_set *set) 宏: 检查文件描述符 fd 是否在 fd_set 集合中并且已就绪。

 

select() 函数的声明如下:

#include <sys/select.h>

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);
  • nfds: 需要监视的文件描述符的最大值加 1。
  • readfds: 指向需要监视读事件的文件描述符集合的指针。
  • writefds: 指向需要监视写事件的文件描述符集合的指针。
  • exceptfds: 指向需要监视异常事件的文件描述符集合的指针。
  • timeout: 指定 select() 的超时时间。
    • NULL: 无限期阻塞,直到有文件描述符就绪。
    • timeval 结构体:指定超时时间(秒和微秒)。
    • timeval 结构体的值都为 0:非阻塞,立即返回。

它的过程可以用下面的图表示:

multiplexing_flow

 

  1. 初始化阶段:
// 声明文件描述符集合
fd_set reads, temps;
int result;
  1. 始化文件描述符集合:
// 初始化文件描述符集合
FD_ZERO(&reads);
FD_SET(0, &reads);
  1. 设置超时
// 设置超时
struct timeval timeout;
timeout.tv_sec = 5;
timeout.tv_usec = 0;
  1. 调用select():
result = select(1, &temps, NULL, NULL, &timeout);

if(result < 0) {
    error_handling("select() error");
}
else if (result == 0) {
    error_handling("tiemout");
}
  1. 处理新连接和数据
else if(FD_ISSET(0, &temps)) {
    char buf[BUF_SIZE];
    int str_len = read(0, buf, BUF_SIZE);
    printf("%s", buf);
}

为了更好理解这个过程,下面通过一个举例来解释这个过程:

你开了一家很受欢迎的小餐馆,有很多客人同时来吃饭。你只雇佣几个非常能干的服务员。这些服务员不会只盯着一桌客人,而是会同时观察很多桌客人。

  • 服务员巡视: 服务员会时不时地看看哪桌客人需要服务(比如想点菜、要加水、要结账)。
  • 发现需求: 当服务员发现某桌客人举手示意,或者菜上完了,需要收拾盘子时,就知道这桌客人“准备好了”。
  • 提供服务: 服务员就走过去,为这桌客人提供相应的服务。
  • 继续巡视: 服务完这桌客人,服务员又会继续观察其他桌客人。

多路复用的过程如下:

想象你有一张“客人清单”(这个清单就是 fd_set,里面记录着你想关注的客人,也就是网络连接)。

  1. 准备清单: 你把所有你想要关注的客人的名字写在这张清单上(把所有你想监视的网络连接的套接字添加到 fd_set 中)。
  2. 巡视: 服务员盯着这张清单上的客人。如果哪个客人准备好了(比如他们想点菜,或者吃完了)发送数据或接收数据。
  3. 发现需求: 当服务员发现清单上的某个客人准备好了(对应的网络连接上有数据了,或者可以发送数据了),它会告诉你。
  4. 处理客人: 你看到服务员告诉你哪个客人准备好了,你就去处理那个客人的需求(读取或发送数据)。
  5. 继续观察: 处理完这个客人,你又回到步骤 2,让服务员继续观察清单上的其他客人。