本章为网络编程 —— C语言Socket编程的期末复习专题。
在这一节中,将复习一些计算机网络基础知识,以及网络编程的概念性知识。
计算机网络是什么?
根据维基百科的解释:计算机网络是一组共享网络节点上的资源或由网络节点提供的资源的计算机。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地址有两个主要功能:
TCP协议 (Transmission Control Protocol) 和 UDP协议 (User Datagram Protocol)。 这两个协议都属于传输层协议,它们负责在网络中的两台计算机之间传输数据。
每台计算机上可以运行多个网络应用程序,每个应用程序都需要一个唯一的端口号来与其他计算机上的应用程序进行通信。端口 (Port) 号是一个0到65535之间的数字。端口的作用就是区分同一台计算机上的不同网络应用程序。
它们之间的关系:
它们共同协作,完成网络数据的传输过程:
Socket是什么?Socket由什么元素唯一确定?
Socket是操作系统提供的一种编程接口,应用程序可以通过这个接口来发送和接收网络数据。可以把Socket想象成应用程序之间进行网络通信的端点。就像打电话时需要手机一样,网络上的应用程序进行通信也需要Socket。
一个Socket的唯一标识由以下几个元素组成:
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)。
常见的应用层、传输层、网络层协议分别有哪些,有什么用?
应用层协议是网络协议栈的最顶层,它直接为应用程序提供服务。下面是一些常见的协议:
网络层协议主要负责在网络中路由数据包。
ping
命令就是使用了ICMP协议来测试网络连接是否畅通。当网络出现问题时,路由器也可能使用ICMP协议向源主机发送错误报告。ICMP用于传递网络控制消息和错误报告。
IP地址和MAC地址有什么区别?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-ACK
第三步:ACK
四次挥手:
大端序 (Big-Endian) 和小根序 (Little-Endian) 有什么区别?
字节序指的是计算机系统中存储多字节数据类型(例如 int
, short
, long
等)时,字节的排列顺序。对于一个多字节的数据,它由多个字节组成,这些字节在内存中可以按照不同的顺序排列。主要有两种排列方式:大端序和小端序。
0x12345678
(十六进制表示)。0x12
是最高有效字节。0x78
是最低有效字节。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 地址的数字表示时,就需要考虑使用字节序转换函数。对于其他数据,例如字符串或单字节数据,则不需要进行转换。
如果想要并发 (Concurrently) 处理多个请求,我们一般可以使用哪些方法?
我们可以使用如下方法:
后续会具体解析。
程序 (Program) 和进程 (Process) 之间有什么区别?
特性 | 程序 (Program) | 进程 (Process) |
---|---|---|
本质 | 静态的指令集合 | 动态的执行实例 |
状态 | 被动的,存储在磁盘上 | 主动的,正在运行 |
生命周期 | 长期存在,直到被删除 | 短暂存在,从启动到结束 |
资源 | 不占用系统资源(除了存储空间) | 占用系统资源(内存、CPU 时间等) |
关系 | 一个程序可以创建多个进程 | 一个进程对应一个正在执行的程序 |
TCP客户端和服务器之间的交互过程大致可以描述为如下步骤和流程图:
客户端 (Client):
socket()
函数创建一个Socket。connect()
函数,指定服务器的IP地址和端口号,向服务器发起连接请求。write()
函数向服务器发送数据,使用 read()
函数接收来自服务器的数据。
服务器端 (Server):
socket()
函数创建一个新的Socket。accept()
函数接受连接。 accept()
会创建一个新的Socket用于与该客户端进行通信。read()
函数接收来自客户端的数据,使用 write()
函数向客户端发送数据。在进行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使用相同的协议和地址族。
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相关的连接。关闭文件描述符后,该文件描述符将不再有效,不能再用于任何操作。
close()
会发起正常的TCP四次挥手断开连接过程。 如果还有数据在发送缓冲区中,内核会尝试发送完这些数据再关闭连接。close()
会立即释放与该Socket相关的资源。在服务器端,通常需要关闭两个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交互的过程中,时常遇到地址转换的问题。
通常需要进行字节序转换的数据类型包括
htons()
在发送前转换,使用 ntohs()
在接收后转换。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客户端和服务器之间的交互过程大致可以描述为如下步骤和流程图:
客户端:
socket()
系统调用完成,但会指定 SOCK_DGRAM
类型来表明这是一个 UDP Socket。sendto()
系统调用将数据报发送到指定的服务器地址和端口。recvfrom()
系统调用来接收服务器发送的数据报。close()
系统调用关闭 UDP Socket。
服务器:
SOCK_DGRAM
类型。recvfrom()
系统调用来接收来自客户端的数据报。sendto()
系统调用向发送数据报的客户端发送响应。与 TCP 相比,UDP 最大的特点是它是无连接的,并且不保证可靠传输。
listen()
, connect()
和accept()
的过程,而是直接发送了数据。
UDP 服务器端与 TCP 服务器端的有如下不同之处:
SOCK_STREAM
创建流式套接字,提供面向连接的、可靠的字节流服务。SOCK_DGRAM
创建数据报套接字,提供无连接的、不可靠的数据报服务。int servSock;
// Create UDP socket
if ((servSock = socket(PF_INET, SOCK_DGRAM, 0)) < 0) {
perror("socket() failed");
exit(1);
}
listen()
和 accept()
:
listen()
监听连接请求,然后调用 accept()
接受客户端的连接,创建一个新的套接字用于与该客户端通信。recvfrom()
接收数据:
read()
或 recv()
从已建立的连接套接字接收数据。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);
sendto()
发送数据:
write()
或 send()
通过已建立的连接套接字发送数据。目标客户端已经通过 accept()
确定。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");
accept()
成功后,会获得一个与特定客户端连接的新的套接字,后续的通信都通过这个新的套接字进行,客户端的地址信息通常在 accept()
调用中获得。
下面是一个完整的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进行一些自定义设置,一般使用如下两个函数:
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_REUSEADDR
,SO_RCVTIMEO
,SO_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_STREAM 或 SOCK_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 |
向多个接收者发送消息一般使用广播 (Broadcast) 和组播 (Multicast) 技术。
255.255.255.255
,或者当前网段的广播地址。组播使用特殊的 D 类 IP 地址,范围从 224.0.0.0
到 239.255.255.255
。
224.0.0.0
到 224.0.0.255
是预留的本地链路组播地址,用于在本地网络段内通信。224.0.1.0
到 238.255.255.255
是全球范围的组播地址。239.0.0.0
到 239.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;
}
多进程服务器是一种处理并发连接的常见方法,它通过创建多个进程来同时处理多个客户端的请求。
多进程服务器的基本工作流程如下:
accept()
函数接受连接,并创建一个新的连接 Socket。fork()
函数创建一个新的子进程。
当多进程服务器accept
一个连接后,会调用fork()
函数创建一个新的进程去处理这次对话,而其本身继续监听,重复这个过程。
fork()
是一个 Unix/Linux 系统调用,用于创建一个新的进程。
fork()
时,操作系统会创建一个几乎与父进程完全相同的副本。fork()
会创建一个新的进程地址空间,这个地址空间是父进程地址空间的逻辑副本。这意味着子进程拥有父进程用户空间内存的一份拷贝,包括:
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)
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()
整合在一起,就可以得到多进程服务器的基本工作流程:
accept()
接受连接,得到一个新的已连接套接字。fork()
创建一个子进程。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()
。
除了整合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;
}
多线程服务器是另一种处理并发连接的常见方法,它通过创建多个线程来同时处理多个客户端的请求。
多线程服务器的基本工作流程如下:
accept()
函数接受连接,并创建一个新的连接 Socket。pthread_create()
函数创建一个新的子线程。与多进程服务器不同的是,我们使用了线程。它们的区别如下:
我们可以通过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);
sem_init()
sem_wait()
: 尝试获取信号量。
sem_post()
: 将信号量的值加 1。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) 是一种允许单个进程或线程处理多个并发连接的技术。与为每个连接创建一个新的进程或线程不同,多路复用通过轮询或事件通知的方式,高效地管理多个套接字上的 I/O 事件。
多路复用的核心在于用一个或少量的进程/线程来监视多个文件描述符(包括Socket)的状态。当某个文件描述符准备好进行 I/O 操作(例如,有数据可读,可以发送数据等)时,操作系统会通知该进程/线程,然后该进程/线程就可以对相应的套接字进行操作。
select()
系统调用允许程序监视多个文件描述符,等待其中一个或多个文件描述符变为“就绪”状态(可读、可写或有异常发生)。
主要步骤:
fd_set
): 使用 fd_set
结构来存储需要监视的文件描述符。fd_set
可以包含多个文件描述符。select()
函数: select()
函数会阻塞,直到有一个或多个被监视的文件描述符准备就绪,或者超时。select()
返回后,需要检查 fd_set
中哪些文件描述符处于就绪状态。相关函数和数据结构:
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:非阻塞,立即返回。它的过程可以用下面的图表示:
// 声明文件描述符集合
fd_set reads, temps;
int result;
// 初始化文件描述符集合
FD_ZERO(&reads);
FD_SET(0, &reads);
// 设置超时
struct timeval timeout;
timeout.tv_sec = 5;
timeout.tv_usec = 0;
select()
:result = select(1, &temps, NULL, NULL, &timeout);
if(result < 0) {
error_handling("select() error");
}
else if (result == 0) {
error_handling("tiemout");
}
else if(FD_ISSET(0, &temps)) {
char buf[BUF_SIZE];
int str_len = read(0, buf, BUF_SIZE);
printf("%s", buf);
}
为了更好理解这个过程,下面通过一个举例来解释这个过程:
你开了一家很受欢迎的小餐馆,有很多客人同时来吃饭。你只雇佣几个非常能干的服务员。这些服务员不会只盯着一桌客人,而是会同时观察很多桌客人。
多路复用的过程如下:
想象你有一张“客人清单”(这个清单就是 fd_set
,里面记录着你想关注的客人,也就是网络连接)。
fd_set
中)。