【网络编程】socket编程和TCP协议
1. 什么是Socket?
- 如果你想和远方的朋友通电话,但是,没有办法直接把自己的声音放在电线上变成电流信号,你需要使用电话机拿起听筒拨号,而这个电话就是Socket,它让你简单方便地完了电流通话。从我们编程的角度来看,我们直接使用TCP传输信息,需要考虑的东西太多了,而Scoket替我们封装实现了TCP,我们只需使用Socket的API,即可间接完成TCP通信。
-
Socket 是网络通信的“接口”,也就是程序和网络之间的中介。它为开发者提供了一套通用的函数或 API,允许程序通过网络发送和接收数据。具体地说,Socket 抽象了网络底层的细节,比如数据包的传输、协议的选择、错误处理等,开发者只需要使用 Socket 提供的函数来建立连接和传输数据,而不需要关心网络底层的实现。
-
Socket 的概念最初是在 1983 年由 Berkeley Software Distribution (BSD) Unix 系统引入的。它旨在为网络通信提供一个统一的接口,使开发者能够方便地在不同的计算机之间进行数据传输。
-
随着 TCP/IP 协议的广泛采用,Socket 编程变得越来越重要。TCP/IP 为计算机网络提供了一个标准化的通信协议,而 Socket 则成为了实现这些协议的编程接口。
-
BSD Unix 提供的 Socket API 成为后来许多操作系统和编程语言实现网络通信的基础。许多现代系统,包括 Linux、Windows 和 macOS,都采用了类似的 Socket API。
-
1990 年代,随着互联网的快速发展,Socket API 进行了多次扩展,以支持新的协议(如 UDP)和功能(如多播) 进入 21 世纪,Socket 编程广泛应用于各种网络应用,包括网页浏览器、聊天程序、在线游戏和分布式系统。它成为了现代网络开发的基础。
2. 网络通信三要素
Socket又被简称为: 网络套接字
3. Socket与TCP的关系
Socket 可以被视为对网络协议的封装或使用协议的工具。它提供了一组 API,让开发者能够在应用层与网络协议(如 TCP 或 UDP)进行交互。通过 Socket,开发者可以方便地创建连接、发送和接收数据,而不需要直接处理底层协议的细节。因此,Socket 是在应用程序和网络协议之间的一个抽象层。
你简单的这样来理解:socket是一个网络编程函数,这个函数根据传入参数不同,就可以支持对应的协议通讯
4. 如何进行Socket编程(流程)
使用Socket编程,说白了就是如何使用Socket函数。重点是理解整个流程。
Socket 编程的基本步骤如下(咱们以TCP协议为例,不同的协议,流程会略有差别,大同小异):
- 创建 Socket:
- 使用
socket()
函数创建一个 Socket,指定协议族(如 IPv4)和类型(如 TCP)。
- 绑定地址:
- 使用
bind()
函数将 Socket 绑定到一个特定的 IP 地址和端口上。
- 监听连接(仅适用于服务器):
- 使用
listen()
函数监听传入的连接请求。
- 接受连接(仅适用于服务器):
- 使用
accept()
函数接受连接,返回一个新的 Socket 用于与客户端通信。
- 连接到服务器(仅适用于客户端):
- 使用
connect()
函数连接到服务器的 Socket。
- 发送和接收数据:
- 使用
send()
和recv()
(或write()
和read()
)函数进行数据传输。
- 关闭 Socket:
- 使用
close()
函数关闭 Socket,释放资源。
5. TCP服务端的实现
在Linux下的服务端实现(请在linux系统上部署)
#include <iostream> // 引入输入输出库
#include <sys/socket.h> // 引入 Socket 库
#include <netinet/in.h> // 引入 Internet 地址族
#include <unistd.h> // 引入 UNIX 标准库
#include <cstring> // 引入字符串操作库,用于 memset()int main() {int server_fd, new_socket; // 声明服务器 Socket 和新的客户端 Socketstruct sockaddr_in address; // 声明用于存储地址信息的结构体int opt = 1; // 设置选项int addrlen = sizeof(address); // 地址结构体的大小const int PORT = 8080; // 服务器监听的端口号// 创建 Socketserver_fd = socket(AF_INET, SOCK_STREAM, 0);if (server_fd == 0) { // 检查 Socket 创建是否成功std::cerr << "Socket creation failed!" << std::endl; // 输出错误信息return 1; // 退出程序}// 设置 Socket 选项,允许重用地址setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));// 设置服务器地址信息address.sin_family = AF_INET; // 使用 IPv4 地址族address.sin_addr.s_addr = INADDR_ANY; // 允许接收来自任何 IP 地址的连接address.sin_port = htons(PORT); // 设置监听的端口号// 将 Socket 绑定到指定地址和端口if (bind(server_fd, (struct sockaddr*)&address, sizeof(address)) < 0) {std::cerr << "Bind failed!" << std::endl; // 输出绑定失败的信息return 1; // 退出程序}// 开始监听传入的连接请求,队列长度为 3if (listen(server_fd, 3) < 0) {std::cerr << "Listen failed!" << std::endl; // 输出监听失败的信息return 1; // 退出程序}std::cout << "Server is listening on port " << PORT << "..." << std::endl; // 输出服务器监听状态// 无限循环,接受客户端连接while (true) {// 接受客户端的连接请求new_socket = accept(server_fd, (struct sockaddr*)&address, (socklen_t*)&addrlen);if (new_socket < 0) { // 检查连接是否成功std::cerr << "Accept failed!" << std::endl; // 输出接受连接失败的信息continue; // 继续下一次循环}// 接收客户端发送的消息char buffer[1024] = {0}; // 创建接收缓冲区ssize_t bytes_read = recv(new_socket, buffer, sizeof(buffer), 0); // 接收数据if (bytes_read > 0) { // 检查是否成功接收数据std::cout << "Client: " << buffer << std::endl; // 输出客户端发送的消息// 发送响应消息给客户端const char* message = "Hello from server"; // 响应消息send(new_socket, message, strlen(message), 0); // 发送数据}// 关闭与客户端的连接close(new_socket); // 关闭客户端 Socket}// 关闭服务器 Socketclose(server_fd); // 关闭服务器 Socketreturn 0; // 正常退出程序
}
6. Socket重要数据结构和函数解析
6.1. 关于sockaddr_in 结构体
-
sockaddr_in
是用于处理 IPv4 地址的结构体,它用于存储网络套接字(Socket)的地址信息,通常在使用bind()
、connect()
、accept()
等网络相关函数时会用到。 -
sockaddr_in
是sockaddr
结构体的一个专用版本,它更方便地处理 IPv4 地址,而sockaddr
是通用的套接字地址结构体。由于网络函数通常要求参数类型是sockaddr
,我们会将sockaddr_in
类型强制转换为sockaddr
来使用。
为什么不直接用sockaddr,非得使用sockaddr_in ?这是因为:sockaddr
是通用的,但是它不易用
6.1.1. sockaddr_in
结构体定义
在头文件 <netinet/in.h>
中,sockaddr_in
结构体的定义如下:
struct sockaddr_in {sa_family_t sin_family; // 地址族(Address family),即协议类型in_port_t sin_port; // 16 位的端口号,网络字节序(大端序)struct in_addr sin_addr; // 32 位的 IPv4 地址char sin_zero[8]; // 填充字段,保持与 sockaddr 结构体的大小一致
};
6.1.2. 各个字段的详细解释
sin_family
:
- 该字段指定了地址族,必须设置为
AF_INET
,表示使用 IPv4 协议族(Internet Protocol version 4)。
address.sin_family = AF_INET;
sin_port
:
- 该字段存储 16 位的端口号,表示与服务器或客户端进行通信的端口号。它必须使用网络字节序(大端序)来存储,因此在代码中经常会使用
htons()
函数来将主机字节序(小端序)转换为网络字节序。
address.sin_port = htons(8080); // 将端口号 8080 转换为网络字节序
sin_addr
:
- 这是存储 IPv4 地址的结构体,使用的是
struct in_addr
类型。它实际上是一个 32 位的整数,通常通过inet_addr()
或inet_pton()
函数来将点分十进制格式的 IP 地址转换为这个字段可接受的格式。
address.sin_addr.s_addr = inet_addr("127.0.0.1"); // 将点分十进制的 IP 地址转换为网络字节序
sin_zero
:
- 该字段是一个长度为 8 字节的填充字段,其作用是使
sockaddr_in
的大小与sockaddr
结构体保持一致。这个字段通常不被使用,直接填充为 0 即可。
6.1.3. sockaddr_in
和 sockaddr
的关系
sockaddr_in
是用于 IPv4 的专用结构体,而网络函数(如bind()
、connect()
、accept()
)需要传递的参数类型是通用的sockaddr
结构体。为了兼容,通常我们会将sockaddr_in
强制转换为sockaddr
类型:- 在上面的服务器代码中,
address
是sockaddr_in
类型的变量,但在bind()
函数中我们强制转换为sockaddr*
来使用。
bind(server_fd, (struct sockaddr*)&address, sizeof(address));
6.2. socket 函数
socket()
函数是网络编程中创建套接字的基础函数,它用于创建一个套接字并返回文件描述符,该描述符可以用于后续的网络通信操作。
6.2.1. socket()
函数原型
int socket(int domain, int type, int protocol);
6.2.2. 参数详解
6.2.3. 返回值
- 成功时,返回套接字的文件描述符(一个整数,表示套接字),后续通过这个文件描述符来操作该套接字。
- 失败时,返回
-1
,并设置errno
来指示具体错误原因。
6.2.4. 参数举例
socket(AF_INET, SOCK_STREAM, 0)
:
- 创建一个使用 IPv4 地址的 TCP 套接字,用于基于连接的通信(如 Web、FTP、SSH 等)。
socket(AF_INET, SOCK_DGRAM, 0)
:
- 创建一个使用 IPv4 地址的 UDP 套接字,用于无连接的通信(如 DNS 查询、视频流传输等)。
socket(AF_INET6, SOCK_STREAM, 0)
:
- 创建一个使用 IPv6 地址的 TCP 套接字。
socket(AF_UNIX, SOCK_STREAM, 0)
:
- 创建一个 UNIX 域套接字,用于本地进程间通信。
6.2.5. 常见错误
EACCES
:创建套接字时权限不足。通常在使用原始套接字时,需要 root 权限。EAFNOSUPPORT
:指定的地址族不被支持。EINVAL
:无效的参数组合。EMFILE
:进程打开的文件描述符过多,超过了系统限制。ENFILE
:系统打开的文件描述符总数超过了限制。
6.3. 关于setsockopt 函数
在 TCP 网络编程中,setsockopt()
是一个常用的函数,用于配置 Socket 的一些行为或参数。
6.3.1. setsockopt是起什么作用?
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
setsockopt()
:这个函数的作用是设置一个套接字的选项。其函数原型如下:
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
server_fd
:这是之前通过socket()
创建的服务器套接字文件描述符。SOL_SOCKET
:表示要操作的级别是套接字本身(而不是某个特定协议的选项)。SOL_SOCKET
是用于通用套接字选项的常量。SO_REUSEADDR
:这个选项允许重用本地地址。在关闭套接字后,TCP 会等待一段时间(TIME_WAIT 状态)才能重新使用相同的端口和地址。如果你设置了SO_REUSEADDR
,即使上一个连接还没有完全释放(处于TIME_WAIT
状态),你也可以立即绑定相同的地址和端口,避免绑定失败。&opt
:opt
是一个整型变量,通常设置为 1,表示启用SO_REUSEADDR
选项。&opt
是指向该变量的指针,用于传递给setsockopt()
。sizeof(opt)
:表示opt
变量的大小,通常是 4 个字节(整数)。
6.3.2. SO_REUSEADDR
的作用
当服务器程序退出并重启时,可能会遇到以下错误:
bind: Address already in use
- 这是因为在程序关闭时,操作系统并没有立即释放该端口,而是进入了
TIME_WAIT
状态,这段时间内无法重新绑定该端口。 - 通过设置
SO_REUSEADDR
选项,即使在这种情况下,服务器也可以立即重新绑定同一个端口,而无需等待TIME_WAIT
状态结束。
6.3.3. 这个选项必须吗?
- 不是必须的,但很常用:
- 在开发和调试阶段,如果服务器频繁重启,设置
SO_REUSEADDR
选项可以避免因为端口占用而无法绑定的问题。 - 在生产环境下,开启
SO_REUSEADDR
可以让服务器在崩溃或重启后快速恢复,而不必等待旧连接释放端口。
6.3.4. 能设置哪些选项?
setsockopt
函数可以设置多种不同的套接字选项,这些选项控制了套接字的行为和配置。选项的种类可以根据不同的协议和操作层次(例如,套接字层、TCP/IP层)进行划分。我们主要介绍一些常用的选项,以及它们的作用和使用场景。
6.3.4.1. setsockopt
函数原型
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
这几个参数的含义:
sockfd
:要设置选项的套接字文件描述符。level
:指定选项的协议级别(例如SOL_SOCKET
、IPPROTO_TCP
、IPPROTO_IP
)。optname
:要设置的具体选项。optval
:指向选项值的指针。optlen
:选项值的大小。
6.3.4.2. 常见的 setsockopt
选项
6.3.4.2.1. 通用套接字选项 (SOL_SOCKET
级别)
这些选项适用于所有协议(TCP、UDP 等),属于套接字层级的配置。
选项名 | 描述 |
---|---|
SO_REUSEADDR | 允许重用本地地址(IP 地址和端口)。 |
SO_REUSEPORT | 允许多个套接字绑定到同一个 IP 地址和端口。 |
SO_KEEPALIVE | 开启 TCP 保持连接(Keep - Alive)机制,定期检测连接是否仍然存活。 |
SO_RCVBUF | 设置接收缓冲区大小。 |
SO_SNDBUF | 设置发送缓冲区大小。 |
SO_RCVTIMEO | 设置接收数据的超时时间(非阻塞模式下可以设定接收超时)。 |
SO_SNDTIMEO | 设置发送数据的超时时间(非阻塞模式下可以设定发送超时)。 |
SO_BROADCAST | 允许发送广播消息。 |
SO_LINGER | 控制 close() 时是否等待套接字缓冲区中的数据发送完毕。 |
SO_ERROR | 获取上一次套接字操作的错误状态。 |
SO_OOBINLINE | 是否将带外数据(Out - of - Band 数据)内联到正常数据流中。 |
6.3.4.2.2. TCP 协议选项 (IPPROTO_TCP
级别)
这些选项专门针对 TCP 协议。
选项名 | 描述 |
---|---|
TCP_NODELAY | 禁用 Nagle 算法,立即发送小数据包,适用于需要低延迟的应用。 |
TCP_MAXSEG | 设置最大段大小(MSS,Maximum Segment Size),即每次发送的最大数据量。 |
TCP_KEEPIDLE | TCP 保活(Keep - Alive)首次探测之前的空闲时间。 |
TCP_KEEPINTVL | TCP 保活探测之间的间隔时间。 |
TCP_KEEPCNT | 在连接断开前的最大保活探测次数。 |
TCP_QUICKACK | 控制 TCP 是否尽快发送 ACK 包,减少延迟。 |
6.3.4.2.3. IP 协议选项 (IPPROTO_IP
级别)
选项名 | 描述 |
---|---|
IP_TTL | 设置 IP 数据报的生存时间(Time To Live, TTL)。 |
IP_MULTICAST_TTL | 设置多播数据包的 TTL。 |
IP_MULTICAST_IF | 指定发送多播数据包的网络接口。 |
IP_MULTICAST_LOOP | 控制本地是否接收到自己发送的多播数据包。 |
IP_ADD_MEMBERSHIP | 加入多播组(用于接收多播数据)。 |
IP_DROP_MEMBERSHIP | 退出多播组。 |
6.3.4.3. UDP 协议选项 (IPPROTO_UDP
级别)
UDP 协议是无连接的协议,因此大多数套接字选项是通用的,较少特定的 UDP 选项。
6.3.4.4. IPv6 协议选项 (IPPROTO_IPV6
级别)
这些选项适用于 IPv6 网络编程,和 IPPROTO_IP
类似,但针对 IPv6 协议
选项名 | 描述 |
---|---|
IPV6_V6ONLY | 限制套接字只能使用 IPv6 地址,而不能同时支持 IPv4 和 IPv6。 |
IPV6_MULTICAST_HOPS | 设置多播消息在网络中的最大跳数(类似于 IPv4 中的 TTL)。 |
IPV6_ADD_MEMBERSHIP | 加入 IPv6 多播组。 |
IPV6_DROP_MEMBERSHIP | 退出 IPv6 多播组。 |
6.4. bind 函数
bind()
函数的主要作用是将套接字与特定的本地地址(IP 地址)和端口号绑定起来,使得该套接字可以通过指定的地址和端口接收数据。bind()
函数通常用于服务器端的套接字编程,以便为套接字分配一个固定的本地地址和端口。
6.4.1. 函数原型
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
sockfd
:套接字描述符,由socket()
函数返回,表示需要绑定的套接字。addr
:指向sockaddr
结构体的指针,包含要绑定的 IP 地址和端口信息。addrlen
:addr
的大小,通常为sizeof(struct sockaddr_in)
。
6.4.2. 参数详解
sockfd
:
套接字描述符,由socket()
函数返回,表示要绑定的套接字。bind()
函数使用这个套接字和本地地址进行绑定操作。addr
:
这是一个指向sockaddr
结构体的指针,它包含了要绑定的地址和端口信息。实际使用时,大多数情况下传递的是sockaddr_in
结构体(IPv4)或者sockaddr_in6
结构体(IPv6)的地址,并通过强制类型转换为sockaddr
结构体。
sockaddr_in
结构体:
struct sockaddr_in {sa_family_t sin_family; // 地址族(AF_INET 表示 IPv4)in_port_t sin_port; // 端口号 (使用 `htons()` 将端口号转换为网络字节序)struct in_addr sin_addr; // IP 地址 (使用 `inet_addr()` 或 `INADDR_ANY`)char sin_zero[8]; // 填充字节,保持与 `sockaddr` 结构体的大小一致
};
sin_family
:协议族,通常为AF_INET
,表示 IPv4 地址族。sin_port
:要绑定的端口号,必须使用htons()
将主机字节序转换为网络字节序。sin_addr
:要绑定的 IP 地址,可以使用inet_addr()
转换字符串形式的 IP 地址,或者使用INADDR_ANY
表示绑定所有可用的网络接口地址。sin_zero
:填充字段,用来保持sockaddr_in
与sockaddr
结构体大小一致。
addrlen
:
该参数指定地址的长度,通常为sizeof(struct sockaddr_in)
或者sizeof(struct sockaddr_in6)
,具体取决于使用的是 IPv4 还是 IPv6 地址。
6.4.3. 返回值
-
成功时返回
0
。 -
失败时返回
-1
,并设置errno
以指示错误类型。常见错误包括: -
EADDRINUSE
:指定的地址已经被使用,通常是端口号冲突。 -
EADDRNOTAVAIL
:指定的 IP 地址不可用。 -
EBADF
:无效的文件描述符,通常是传递的sockfd
非法。 -
EINVAL
:套接字已经绑定,不能重复绑定。 -
ENOTSOCK
:sockfd
不是一个套接字。
6.4.5. 为什么需要 bind()
- 服务器端:
在服务器端,bind()
函数必须调用,它将套接字绑定到一个特定的端口和 IP 地址,使得客户端能够通过该地址和端口连接到服务器。如果不调用bind()
,操作系统可能会自动分配一个临时的本地端口,这对于服务器来说是不可接受的,因为服务器需要一个固定的端口号以供客户端连接。 - 客户端:
在客户端编程中,bind()
通常不是必需的。客户端套接字一般依赖于系统自动分配的本地端口,以便与服务器通信。如果有特殊需求(比如希望客户端使用特定的本地 IP 地址和端口),也可以调用bind()
绑定。
6.5. listen 函数
listen()
函数在服务器端网络编程中用于将一个套接字设置为监听状态,使其能够接收来自客户端的连接请求。它是服务器端 TCP 套接字生命周期中一个关键步骤,用于处理被动套接字(即等待客户端连接的套接字)
6.5.1. listen()
函数原型
int listen(int sockfd, int backlog);
6.5.2. 参数详解
-
sockfd
(套接字文件描述符):- 这是之前通过
socket()
函数创建的套接字的文件描述符,通常是一个使用SOCK_STREAM
(TCP)类型的套接字。 - 该套接字必须已经通过
bind()
函数绑定到一个特定的 IP 地址和端口上。
- 这是之前通过
-
backlog
(连接队列的最大长度):backlog
参数指定内核为套接字维护的已完成连接队列和未完成连接队列的总长度。这个值表示最大可以同时等待处理的客户端连接请求数量。- 已完成连接队列:保存已经完成 TCP 三次握手的连接。
- 未完成连接队列:保存正在等待完成三次握手的连接。
- 当队列满了之后,如果有新的连接请求到达,它们会被拒绝,客户端将收到
ECONNREFUSED
错误。 - 值可以根据服务器的预期负载进行设置。例如,高并发服务器可能设置较大的
backlog
值。
6.5.3. 返回值
- 成功时,返回
0
。 - 失败时,返回
-1
,并设置errno
来指示错误
6.5.4. listen()
函数的功能和工作流程
-
将套接字从主动状态转换为被动状态:
- 调用
listen()
之前,服务器端的套接字处于主动状态,它只能用于发起连接请求(比如客户端连接其他服务器)。 - 调用
listen()
后,套接字被转换为被动套接字,即它不会发起连接,而是等待客户端连接请求。
- 调用
-
维护连接请求队列:
- 内核为套接字维护两个队列:未完成队列和已完成队列。
- 未完成队列:存储那些已发起连接但尚未完成三次握手的客户端请求。
- 已完成队列:存储那些已经完成三次握手、等待
accept()
的客户端连接。
backlog
参数决定了这两个队列的总长度上限。如果队列已满,新的连接请求将被拒绝。
- 内核为套接字维护两个队列:未完成队列和已完成队列。
6.5.6. 常见错误及解决方案
-
EBADF
:sockfd
参数不是有效的文件描述符。通常是由于传递了未创建的套接字描述符。
-
ENOTSOCK
:sockfd
文件描述符不是一个套接字,可能误用了其他类型的文件描述符。
-
EADDRINUSE
:- 绑定的地址已被另一个套接字使用。可以使用
setsockopt()
设置SO_REUSEADDR
选项来允许地址重用。
- 绑定的地址已被另一个套接字使用。可以使用
-
EINVAL
:sockfd
套接字未通过bind()
函数绑定到一个本地地址或未设置为 TCP 类型套接字(SOCK_STREAM
)。
-
EOPNOTSUPP
:- 使用了不支持
listen()
操作的套接字类型,比如 UDP 套接字。
- 使用了不支持
6.5.7. listen()
和 TCP 三次握手的关系
- 当服务器调用
listen()
后,它开始等待客户端的连接请求。客户端发起连接请求时,会进行 TCP 的三次握手过程:- 客户端发送 SYN 包给服务器,表示请求建立连接。
- 服务器返回 SYN-ACK 包,表示同意建立连接。
- 客户端返回 ACK 包,连接建立完成。
- 在三次握手完成之前,连接请求会被放入未完成队列;三次握手完成后,连接请求会被移到已完成队列。
- 服务器可以通过
accept()
函数获取已完成连接队列中的客户端连接。
6.6. accept 函数
accept()
函数是服务器端套接字编程中的关键函数,用于从连接队列中取出等待的客户端连接,并为其创建一个新的套接字。通过 accept()
,服务器可以与客户端进行后续通信。
6.6.1. 函数原型
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
sockfd
:服务器的监听套接字描述符,表示等待连接的套接字。addr
:指向sockaddr
结构体的指针,用于存储客户端的地址信息。addrlen
:指向socklen_t
类型变量的指针,传递给accept()
用于表示addr
的长度,并在函数返回时保存客户端地址的长度。
6.6.2. 参数详解
-
sockfd
:- 该参数是通过
socket()
函数创建,并通过bind()
和listen()
函数设定为监听模式的套接字描述符。 sockfd
是服务器用于监听客户端连接的套接字,不能直接用于与客户端通信。accept()
会创建一个新的套接字,用于与客户端进行通信。
- 该参数是通过
-
addr
:- 该参数是一个指向
sockaddr
结构体的指针,服务器使用它来存储客户端的地址信息(如 IP 地址和端口号)。 addr
通常指向一个sockaddr_in
结构体(对于 IPv4)或者sockaddr_in6
结构体(对于 IPv6)。这些结构体包含了客户端的 IP 地址和端口信息。
- 该参数是一个指向
-
addrlen
:- 该参数是一个指向
socklen_t
类型变量的指针,传入时指定addr
结构体的大小,函数返回时则保存实际的地址长度。 - 在调用
accept()
之前,你需要将它设置为sizeof(struct sockaddr_in)
(或者sizeof(struct sockaddr_in6)
对于 IPv6)。函数返回后,addrlen
会被更新为实际的地址大小。
- 该参数是一个指向
例如:
client_address
用于存储客户端的地址,client_len
存储结构体的大小。
struct sockaddr_in client_address;
socklen_t client_len = sizeof(client_address);
6.6.3. 返回值
- 成功时,
accept()
返回一个新的套接字描述符(new_sockfd
),该套接字专用于与该客户端的通信。 - 失败时,
accept()
返回-1
,并设置errno
以指示错误类型。常见错误包括:EAGAIN
或EWOULDBLOCK
:套接字为非阻塞模式,但没有连接请求处于等待状态。EBADF
:无效的文件描述符。ECONNABORTED
:客户端的连接请求被中止。EINTR
:函数调用被中断。EINVAL
:监听套接字无效,可能尚未调用listen()
。ENOTSOCK
:sockfd
不是一个套接字。
6.6.5. accept() 阻塞的处理过程
当服务器调用 accept()
后,如果当前没有客户端连接请求,accept()
会进入 阻塞状态,此时服务器会处于等待状态,直到有新的客户端尝试连接。在阻塞期间,服务器的主线程无法执行其他操作,必须等待 accept()
返回新的连接。
当有客户端连接时:
accept()
解除阻塞,取出连接队列中的第一个连接请求。- 返回用于与该客户端通信的新的套接字描述符。
如果连接队列中没有等待的连接请求,accept()
会继续阻塞,直到新的连接到来。
6.7. recv 函数
6.7.1. 函数定义
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
- 返回值:表示实际接收到的数据字节数,失败时返回
-1
,并设置errno
指示错误。
6.7.2. 参数详解
-
sockfd:
- 类型:
int
- 作用:这是一个套接字描述符,表示你希望从哪个套接字接收数据。通常是通过
socket()
函数创建,或者通过accept()
函数获得的客户端连接套接字。
- 类型:
-
buf:
- 类型:
void *
- 作用:这是一个缓冲区指针,指向用于存储接收到的数据的内存区域。该内存区域由调用者分配,
recv()
函数将接收到的数据复制到这个缓冲区中。
- 类型:
-
len:
- 类型:
size_t
- 作用:表示可以接收的最大字节数。即缓冲区
buf
的大小,recv()
将最多接收len
字节的数据,并将其存储到buf
中。
- 类型:
-
flags:
- 类型:
int
- 作用:这个参数提供额外的控制选项,影响
recv()
的行为。常见的选项包括:
- 类型:
0
:默认模式,没有特殊标志。MSG_PEEK
:从缓冲区中窥视数据,不移除它。这意味着数据仍然保留在套接字接收队列中,下次调用recv()
时仍可以读取到相同的数据。MSG_WAITALL
:等待接收完整的len
字节,直到所有数据被接收或连接关闭。MSG_OOB
:接收带外数据,通常用于 TCP 的紧急数据。
6.7.3. 返回值
- > 0:表示成功接收的数据字节数。
- 0:表示连接已被对方关闭(对于 TCP 连接)。
- -1:表示出现了错误,并设置
errno
指示具体的错误原因。
6.7.4. 常见错误码(errno)
EBADF
:sockfd
不是有效的文件描述符。EAGAIN
或EWOULDBLOCK
:套接字是非阻塞的,但是目前没有可读取的数据。EINTR
:系统调用被信号中断。ECONNRESET
:连接被对方重置(例如客户端突然断开)。
6.7.5. 特别注意
- 阻塞行为:默认情况下,
recv()
是阻塞的。也就是说,当没有数据可以接收时,recv()
会阻塞,直到有数据可用或连接关闭。如果你希望让recv()
在没有数据时立即返回而不是阻塞,可以将套接字设置为非阻塞模式,或者使用MSG_DONTWAIT
标志。 - 连接关闭:当
recv()
返回 0 时,表示对方关闭了连接。在 TCP 连接中,这是一个常见的方式来检测客户端是否断开。 - 数据长度问题:
recv()
并不能保证每次接收的数据量等于你请求的长度len
。数据可能会分多次传送,尤其是在网络不稳定的情况下。因此,如果你需要接收固定大小的数据,你需要检查recv()
的返回值,并在必要时多次调用recv()
以获取完整数据。
6.8. send 函数
send()
函数用于通过已连接的套接字发送数据,通常在 TCP 网络编程中使用。它与 recv()
是对应的操作,recv()
负责接收数据,而 send()
负责发送数据。
6.8.1. 函数定义
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
- 返回值:表示成功发送的字节数,如果出现错误则返回
-1
,并设置errno
来指示错误。
6.8.2. 参数详解
-
sockfd:
- 类型:
int
- 作用:这是一个套接字描述符,表示向哪个套接字发送数据。这个套接字通常是通过
socket()
函数创建,并通过connect()
或accept()
函数获得的有效连接。
- 类型:
-
buf:
- 类型:
const void *
- 作用:指向要发送的数据的缓冲区,即存放要通过网络传输的数据。发送的数据会从该缓冲区中读取。
- 类型:
-
len:
- 类型:
size_t
- 作用:指定要发送的字节数,也就是缓冲区中数据的长度。
send()
函数会尝试发送len
个字节的数据,但不保证一次就能发送全部数据。
- 类型:
-
flags:
- 类型:
int
- 作用:控制发送行为的标志,常用的标志包括:
0
:默认标志,表示没有特殊选项。MSG_DONTWAIT
:非阻塞发送,立即返回而不等待缓冲区可用。如果缓冲区已满,函数会返回-1
并设置errno
为EAGAIN
或EWOULDBLOCK
。MSG_OOB
:发送带外数据(紧急数据)。在 TCP 中,这种数据通常用于传输控制信息。MSG_NOSIGNAL
:阻止在连接被关闭时发送SIGPIPE
信号,防止进程异常终止。
- 类型:
6.8.3. 返回值
- > 0:表示成功发送的字节数,可能小于
len
,这意味着并未将全部数据一次性发送完。 - 0:在
send()
中返回 0 并不常见,通常不会用于指示成功的传输结束。 - -1:表示出现了错误,
errno
会设置为具体的错误码。
6.8.4. 常见错误码(errno)
EBADF
:sockfd
不是有效的文件描述符。EACCES
:尝试发送的数据违反了权限设置。EPIPE
:连接已被对方关闭或中断,向一个关闭的连接发送数据。EAGAIN
或EWOULDBLOCK
:套接字是非阻塞的,但目前无法发送数据,因为发送缓冲区已满。EMSGSIZE
:待发送的数据长度超过了协议允许的最大消息大小。ECONNRESET
:连接被对方重置,通常是由于对方强制关闭了连接。
6.8.5. send()
和 write()
的关系
在很多情况下,send()
和 write()
函数在 TCP 套接字中具有类似的功能。唯一的区别在于 send()
函数有一个额外的 flags
参数,可以提供更多的控制选项,比如 MSG_OOB
发送带外数据。而 write()
是更通用的写入数据的函数,它在网络编程中的功能与 send()
基本一致,但不支持这些额外的标志选项。
6.8.6. 发送数据长度问题
在网络编程中,特别是对于 send()
函数,不能保证一次调用 send()
会将所有数据发送完。特别是在传输大块数据时,send()
可能只发送了一部分数据,然后返回实际发送的字节数。这时需要通过循环调用 send()
来确保所有数据都已发送。
示例代码(循环发送):
ssize_t total = 0;
ssize_t bytes_sent;
const char *data = "A long message";
size_t data_len = strlen(data);// 循环发送直到所有数据发送完毕
while (total < data_len) {bytes_sent = send(sock, data + total, data_len - total, 0);if (bytes_sent == -1) {std::cerr << "Send error" << std::endl;break;}total += bytes_sent;
}
6.8.7. 特别注意
- 阻塞行为:默认情况下,
send()
是阻塞的,也就是说,如果系统的发送缓冲区已满,send()
会等待直到有足够空间可以发送数据。在这种情况下,程序会暂停执行,直到数据成功发送或出现错误。 - 非阻塞模式:如果套接字被设置为非阻塞模式,或者
send()
函数使用了MSG_DONTWAIT
标志,那么当发送缓冲区满时,send()
会立即返回,可能返回-1
,并将errno
设置为EAGAIN
或EWOULDBLOCK
。 - 数据丢失:
send()
函数仅负责将数据交给操作系统内核,实际的数据传输过程由内核管理。如果网络连接出现问题,数据可能无法送达目的地,但send()
并不会返回传输失败,除非连接已经断开(例如,ECONNRESET
或EPIPE
错误
6.8.8. 简单总结一下
send()
用于将数据从应用层发送到网络上,通过套接字通信。- 函数的
flags
参数允许你控制数据发送的行为,例如发送带外数据、非阻塞发送等。 send()
是阻塞的,除非使用了非阻塞模式或相应的flags
。- 数据发送时,
send()
不保证一次发送全部数据,可能需要多次调用才能传输完整数据
6.9. lose 函数
close()
函数用于关闭套接字,当一个应用程序不再需要与远程主机进行通信时,调用此函数可以释放相关的资源。套接字的关闭过程涉及到网络连接的终止和资源的清理。在网络编程中,特别是涉及 TCP 协议时,close()
的行为比较复杂,因为它需要处理连接的安全终止,确保所有数据都成功传输。
6.9.1. 函数定义
int close(int sockfd);
- 返回值:
- 成功返回
0
,失败返回-1
,并设置errno
来指示具体的错误。
6.9.2. 参数详解
- sockfd:
- 类型:
int
- 作用:套接字描述符,即要关闭的套接字。这个描述符是在调用
socket()
、accept()
或connect()
函数后得到的。
- 类型:
6.9.3. 返回值
- 0:表示成功关闭了套接字。
- -1:表示关闭套接字时出现了错误。
errno
被设置为相应的错误码,用来描述错误类型
6.9.4. 常见错误码
EBADF
:sockfd
不是有效的文件描述符,或者已经被关闭。EINTR
:在关闭操作进行时,系统调用被信号中断。ENOSYS
:系统不支持close()
操作。
6.9.5. close()
的作用
- 释放资源:当调用
close()
时,系统会回收该套接字占用的资源,包括网络缓冲区、套接字描述符等。否则会导致资源泄漏,最终可能耗尽系统资源。 - 通知对端:对于基于 TCP 的连接,
close()
函数会通知对端,本地应用程序已经关闭连接,不会再发送数据。TCP 会通过 四次挥手(four-way handshake) 来确保连接的正常终止。
6.9.6. 关于 TCP 协议中的 close()
在 TCP 连接中,close()
不仅仅是简单的关闭文件描述符,它还会触发一系列与 TCP 协议相关的步骤:
- 发送 FIN:当应用程序调用
close()
关闭 TCP 连接时,操作系统会向对端发送一个 FIN(finish)报文,表示本地不会再发送数据。 - 等待对端确认:对端接收到 FIN 报文后,会回复一个 ACK 确认包,表示它已经知道本地关闭了连接。
- 等待对端的 FIN:对端也会在适当的时候发送 FIN 报文,表示它也关闭了连接。
- 接收 ACK:本地再回复一个 ACK,表示已经收到对端的关闭请求,至此连接正式断开。
这种过程称为 TCP 的四次挥手。在调用 close()
后,连接不会立即断开,而是需要等待对端的确认。这个过程会让套接字进入 TIME_WAIT
状态,通常持续约 2 分钟,以确保网络上的延迟不会影响连接的安全关闭。
6.9.7. close()
的行为
- 关闭发送/接收通道:
close()
会同时关闭 TCP 连接的发送和接收通道。这意味着本地程序无法再通过该套接字发送或接收任何数据。 - 数据未发送完:如果在调用
close()
时,发送缓冲区中仍有数据未传输完,操作系统会试图将这些数据继续发送。但如果需要立即中断数据传输,可以使用shutdown()
函数,它提供了更精细的控制。 - 阻塞与非阻塞:调用
close()
后,程序不会等待远程主机的响应,而是立即返回,并把关闭操作交给操作系统处理。这意味着close()
是非阻塞的操作。
6.9.8. close()
与 shutdown()
的区别
close()
关闭整个套接字,包括发送和接收的功能,并释放资源。shutdown()
提供了更精细的控制,可以选择仅关闭发送或接收的通道,而不关闭整个套接字。
shutdown()
的函数签名如下:
int shutdown(int sockfd, int how);
how
参数:控制关闭行为,取值如下:SHUT_RD
:关闭读通道,程序不能再从套接字接收数据。SHUT_WR
:关闭写通道,程序不能再向套接字发送数据。SHUT_RDWR
:同时关闭读写通道,相当于close()
。
6.9.9. 常见问题与注意事项
- 多次
close()
:同一个套接字描述符只能关闭一次,调用close()
后,该套接字描述符变得无效。如果再次调用close()
会返回-1
并设置errno
为EBADF
。 TIME_WAIT
状态:在 TCP 连接中,调用close()
后,套接字可能会进入TIME_WAIT
状态,防止旧数据影响新连接。通常该状态会持续 2 分钟。- 文件描述符复用:在调用
close()
后,文件描述符可能被操作系统重新分配给其他文件或套接字,因此不要再对已经关闭的文件描述符进行任何操作。
6.10. connect 函数
connect()
函数是用于客户端在基于流(如 TCP)或数据报(如 UDP)的网络编程中,发起与服务器连接的函数。对于 TCP,connect()
负责发起三次握手,建立与服务器的连接。而在 UDP 中,虽然不建立连接,但 connect()
用于将客户端套接字与特定的服务器地址绑定。
6.10.1. 函数定义
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 返回值:
- 成功返回
0
。 - 失败返回
-1
,并设置errno
以指示具体的错误。
- 成功返回
6.10.2. 参数详解
-
sockfd:
- 类型:
int
- 作用:要发起连接的套接字描述符,通常是由
socket()
函数创建的。
- 类型:
-
addr:
- 类型:
const struct sockaddr *
- 作用:包含服务器的地址信息,如 IP 地址和端口号。实际上,这是一个通用地址结构指针,通常指向
sockaddr_in
(IPv4)或sockaddr_in6
(IPv6)的结构体。
- 类型:
-
addrlen:
- 类型:
socklen_t
- 作用:指定
addr
结构体的大小,对于 IPv4 通常是sizeof(struct sockaddr_in)
,对于 IPv6 是sizeof(struct sockaddr_in6)
。
- 类型:
6.10.3. 返回值
- 0:表示连接成功。
- -1:表示连接失败,并且设置了
errno
来指示错误原因。
6.10.4. 常见错误码
EADDRNOTAVAIL
:提供的地址不合法。ECONNREFUSED
:服务器拒绝连接(可能是服务器未开启,或未监听指定的端口)。EINPROGRESS
:套接字处于非阻塞模式,连接正在进行中。ETIMEDOUT
:连接尝试超时,服务器没有及时响应。EHOSTUNREACH
:没有到达目标主机的有效路径,主机不可达。
6.10.5. connect()
在 TCP 和 UDP 中的行为
- TCP:在 TCP 中,
connect()
通过三次握手协议与服务器建立可靠的连接。
- 客户端发送 SYN(同步)报文,表示要发起连接。
- 服务器回复 SYN+ACK(同步+确认)报文,表示同意建立连接。
- 客户端发送 ACK(确认)报文,连接建立。
- UDP:在 UDP 中,
connect()
不会建立物理上的连接,因为 UDP 是无连接的协议。但它会设置默认的服务器地址,这样后续通过该套接字发送的数据报会自动发往该地址。
6.10.6. connect()
的阻塞与非阻塞
- 阻塞模式:在默认情况下,
connect()
是阻塞的,即当客户端发起连接时,函数会阻塞,直到连接建立成功或超时失败。阻塞模式适用于简单的客户端应用程序。 - 非阻塞模式:当套接字设置为非阻塞模式时,
connect()
不会阻塞,而是立即返回。如果连接正在进行,则errno
会被设置为EINPROGRESS
。此时应用程序可以通过select()
、poll()
或epoll()
等机制,等待连接完成。
6.10.7. connect()
的常见错误处理
- 超时:如果服务器没有及时响应,
connect()
将返回-1
,并将errno
设置为ETIMEDOUT
。 - 连接被拒绝:如果服务器拒绝连接,
connect()
将返回-1
,并将errno
设置为ECONNREFUSED
。 - 主机不可达:如果没有有效的路由到服务器,
connect()
会返回-1
,并将errno
设置为EHOSTUNREACH
。
7. TCP客户端实现
下面是一个简单的 TCP 客户端示例,分别适用于 Windows 和 Linux。这个客户端会连接到服务器,发送一条消息,并接收服务器的响应。
7.1. Windows 版
#include <iostream> // 引入标准输入输出流库
#include <string> // 引入字符串库
#include <winsock2.h> // 引入Winsock2库,用于网络编程
#include <ws2tcpip.h> // 引入Windows Sockets 2 TCP/IP协议的扩展库 #pragma comment(lib, "ws2_32.lib") // 链接ws2_32.lib库,这是使用Winsock2进行网络编程必需的 int main() {WSADATA wsaData; // 用于WSAStartup的返回状态信息 SOCKET sock = INVALID_SOCKET; // 声明一个SOCKET对象,并初始化为无效套接字 sockaddr_in server; // 用于存储服务器地址信息的结构体 int result; // 用于存储WSAStartup的返回值 // 初始化Winsock库 result = WSAStartup(MAKEWORD(2, 2), &wsaData);if (result != 0) {std::cerr << "WSAStartup失败,错误码: " << result << std::endl;return 1; // 初始化失败,返回1 }// 创建一个流式套接字(TCP) sock = socket(AF_INET, SOCK_STREAM, 0);if (sock == INVALID_SOCKET) {std::cerr << "套接字创建失败,错误码: " << WSAGetLastError() << std::endl;WSACleanup(); // 清理Winsock资源 return 1; // 创建失败,返回1 }// 设置服务器的地址和端口 server.sin_family = AF_INET; // 使用IPv4地址 server.sin_port = htons(8080); // 设置端口号为8081,htons函数用于主机字节序到网络字节序的转换 // 使用inet_pton函数将文本形式的IP地址转换为二进制形式 if (inet_pton(AF_INET, "127.0.0.1", &server.sin_addr) <= 0) {std::cerr << "无效的地址/地址不受支持" << std::endl;closesocket(sock); // 关闭套接字 WSACleanup(); // 清理Winsock资源 return 1; // 转换失败,返回1 }// 连接到服务器 if (connect(sock, (struct sockaddr*)&server, sizeof(server)) < 0) {std::cerr << "连接失败,错误码: " << WSAGetLastError() << std::endl;closesocket(sock); // 关闭套接字 WSACleanup(); // 清理Winsock资源 return 1; // 连接失败,返回1 }// 发送消息到服务器 std::string message = "Hello from client";send(sock, message.c_str(), message.size(), 0); // 发送字符串到服务器 // 接收来自服务器的响应 char buffer[1024] = { 0 }; // 声明一个缓冲区用于接收数据 int bytesRead = recv(sock, buffer, sizeof(buffer) - 1, 0); // 接收数据 if (bytesRead > 0) {std::cout << "服务器: " << buffer << std::endl; // 输出接收到的数据 }else if (bytesRead == 0) {std::cout << "连接被对方关闭。" << std::endl; // 连接被对方关闭 }else {std::cerr << "接收失败,错误码: " << WSAGetLastError() << std::endl; // 接收失败,输出错误码 }// 关闭套接字 closesocket(sock);WSACleanup(); // 清理Winsock资源 return 0; // 程序正常结束
}
7.2. Linux 版
#include <iostream>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>int main() {int sock;struct sockaddr_in server;// 创建 Socketsock = socket(AF_INET, SOCK_STREAM, 0);if (sock < 0) {std::cerr << "Socket creation failed!" << std::endl;return 1;}// 设置服务器地址server.sin_family = AF_INET;server.sin_port = htons(8080);server.sin_addr.s_addr = inet_addr("127.0.0.1");// 连接到服务器if (connect(sock, (struct sockaddr*)&server, sizeof(server)) < 0) {std::cerr << "Connection failed!" << std::endl;return 1;}// 发送消息const char* message = "Hello from client";send(sock, message, strlen(message), 0);// 接收响应char buffer[1024] = {0};recv(sock, buffer, sizeof(buffer), 0);std::cout << "Server: " << buffer << std::endl;// 关闭 Socketclose(sock);return 0;
}
7.3. 说明
-
Windows 版:
-
使用 Winsock API,需要在程序开始时初始化 Winsock,并在结束时清理。
-
使用
closesocket()
函数关闭 Socket。 -
Linux 版:
-
使用 POSIX Socket API,直接使用
close()
函数关闭 Socket。
在运行这些客户端程序之前,请确保相应的服务器端正在运行,并监听相同的 IP 地址和端口(这里使用的是 127.0.0.1
和端口 8080
)。
8. C/S交互和其他延伸
8.1. 如何实现一对一聊天
在 C++ 的 Socket 编程中,实现一对一聊天的基本思路是构建一个客户端(Client)和一个服务端(Server),并让每个客户端之间通过服务器进行消息的转发。具体步骤如下:
8.1.1. 服务端设计
服务端需要接受多个客户端的连接,并为每对用户建立专属的通信通道。实现流程如下:
- 服务端启动并监听某个端口。
- 每当有客户端连接时,服务端接受连接并创建一个独立的线程或使用 I/O 多路复用(如
select
、epoll
)来处理客户端请求。 - 服务端维护一个客户端的连接表,当两个客户端匹配时,将彼此的消息进行转发。
- 实现聊天消息的收发和转发逻辑。
8.1.2. 客户端设计
客户端需要与服务端保持连接,并能够持续地发送和接收消息。实现流程如下:
- 客户端启动后,连接到服务端的指定 IP 和端口。
- 客户端可以发送消息给服务端,服务端将消息转发给目标用户。
- 客户端持续接收从服务端发来的消息,显示在用户界面或控制台。
8.1.3. 具体步骤和代码实现
8.1.4. 服务端代码示例
#include <iostream>
#include <string>
#include <cstring>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <thread>
#include <map>#define PORT 8080
#define BUFFER_SIZE 1024std::map<int, int> client_map; // 存储客户端的配对关系// 处理客户端通信
void handle_client(int client_socket) {char buffer[BUFFER_SIZE];int target_client = client_map[client_socket]; // 获取配对的客户端while (true) {memset(buffer, 0, sizeof(buffer));ssize_t bytes_received = recv(client_socket, buffer, BUFFER_SIZE, 0);if (bytes_received <= 0) {std::cout << "客户端断开连接。" << std::endl;close(client_socket);return;}std::cout << "收到消息: " << buffer << std::endl;// 将消息转发给目标客户端if (client_map.find(target_client) != client_map.end()) {send(target_client, buffer, strlen(buffer), 0);} else {std::cout << "目标客户端未连接。" << std::endl;}}
}int main() {int server_socket;struct sockaddr_in server_addr;// 创建服务器端套接字server_socket = socket(AF_INET, SOCK_STREAM, 0);if (server_socket == 0) {std::cerr << "套接字创建失败。" << std::endl;return -1;}// 初始化地址结构server_addr.sin_family = AF_INET;server_addr.sin_addr.s_addr = INADDR_ANY;server_addr.sin_port = htons(PORT);// 绑定地址到套接字if (bind(server_socket, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {std::cerr << "绑定失败。" << std::endl;return -1;}// 监听客户端连接if (listen(server_socket, 3) < 0) {std::cerr << "监听失败。" << std::endl;return -1;}std::cout << "等待客户端连接..." << std::endl;while (true) {struct sockaddr_in client_addr;socklen_t addr_len = sizeof(client_addr);int new_socket = accept(server_socket, (struct sockaddr*)&client_addr, &addr_len);if (new_socket < 0) {std::cerr << "连接失败。" << std::endl;continue;}std::cout << "新客户端连接: " << inet_ntoa(client_addr.sin_addr) << std::endl;// 这里为了简化,我们直接假设是两客户端配对,client_map 存储配对关系if (client_map.empty()) {client_map[new_socket] = -1; // 第一个客户端,暂时没有配对} else {for (auto& pair : client_map) {if (pair.second == -1) {client_map[new_socket] = pair.first; // 第二个客户端与第一个配对client_map[pair.first] = new_socket;break;}}}// 创建线程处理新客户端std::thread client_thread(handle_client, new_socket);client_thread.detach(); // 分离线程以便独立运行}close(server_socket);return 0;
}
8.1.5. 客户端代码示例
#include <iostream>
#include <cstring>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <thread>#define PORT 8080
#define BUFFER_SIZE 1024// 接收服务器消息
void receive_messages(int socket) {char buffer[BUFFER_SIZE];while (true) {memset(buffer, 0, sizeof(buffer));ssize_t bytes_received = recv(socket, buffer, BUFFER_SIZE, 0);if (bytes_received <= 0) {std::cout << "服务器断开连接。" << std::endl;close(socket);return;}std::cout << "收到消息: " << buffer << std::endl;}
}int main() {int client_socket;struct sockaddr_in server_addr;// 创建客户端套接字client_socket = socket(AF_INET, SOCK_STREAM, 0);if (client_socket < 0) {std::cerr << "套接字创建失败。" << std::endl;return -1;}// 初始化服务器地址server_addr.sin_family = AF_INET;server_addr.sin_port = htons(PORT);server_addr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 服务器 IP// 连接到服务器if (connect(client_socket, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {std::cerr << "连接服务器失败。" << std::endl;return -1;}std::cout << "成功连接服务器。" << std::endl;// 创建线程接收服务器消息std::thread receive_thread(receive_messages, client_socket);receive_thread.detach(); // 分离线程以便独立运行// 发送消息给服务器char message[BUFFER_SIZE];while (true) {std::cin.getline(message, BUFFER_SIZE);send(client_socket, message, strlen(message), 0);}close(client_socket);return 0;
}
8.1.6. 实现说明
- 服务端:服务器监听客户端连接并维护一个客户端配对表
client_map
。当两个客户端配对后,消息就可以在它们之间转发。这里使用了多线程来处理每个客户端的通信。 - 客户端:客户端连接到服务器并启动一个线程用于接收来自服务器的消息,用户可以输入消息并发送给服务器,服务器负责转发消息给配对的客户端。
8.1.7. 改进建议
- 使用 epoll 或 select 替代多线程处理,提高服务器的并发性能。
- 增加 心跳机制 来检测客户端是否断开连接。
- 实现 身份认证 或 聊天室功能,使得用户可以自由选择与谁聊天。
8.2. 常见的服务端高并发方案
在C++ Socket编程中,实现服务端高并发的常见方案主要有以下几种:
8.2.1. 多线程/多进程模型
每个连接由一个独立的线程或进程处理,能够比较简单地实现并发处理。
- 优点:代码易于理解,编写起来较为简单。
- 缺点:线程或进程开销较大,在高并发场景下,大量的线程/进程会带来系统资源消耗和性能瓶颈,特别是在数千甚至数万个连接时。
8.2.2. I/O 多路复用
I/O 多路复用可以通过少量线程处理大量并发连接,常用的方法包括:
-
select:通过一个文件描述符集合监视多个文件描述符是否有 I/O 事件。
- 优点:简单、易用,跨平台支持好。
- 缺点:性能不佳,处理大量连接时,每次调用
select
都要遍历整个描述符集合,效率低。
-
poll:与
select
类似,但没有文件描述符数量限制。- 优点:避免了
select
的文件描述符限制。 - 缺点:与
select
类似,性能仍然不高,遍历整个描述符集合。
- 优点:避免了
-
epoll(Linux 专用):epoll 是 Linux 特有的 I/O 多路复用机制,性能更好,适合处理大量并发连接。
- 优点:不会遍历所有文件描述符,性能优异,适用于高并发场景。
- 缺点:仅限于 Linux 系统,学习曲线稍高。
8.2.3. 异步 I/O(Asynchronous I/O)
异步 I/O 通过事件驱动机制,程序不需要等待 I/O 操作的完成,而是注册事件,事件触发时进行处理。常见的异步 I/O 实现包括:
- Windows:使用
IOCP
(I/O Completion Port)实现异步 I/O 处理。 - Linux:可以使用
libaio
或者基于epoll
实现的异步 I/O。 - 优点:真正的异步,无需阻塞等待 I/O 操作,性能高,适合高并发。
- 缺点:编写异步代码较复杂,调试较困难。
8.2.4. 事件驱动框架
利用现成的事件驱动框架来处理高并发连接,常见的库包括:
- libevent:基于事件的异步 I/O 库,支持
epoll
、kqueue
等多种 I/O 多路复用机制,适合处理大量并发连接。 - libuv:跨平台异步 I/O 库,Node.js 就是基于
libuv
实现的。 - Muduo:C++ 高性能网络库,基于
epoll
和线程池,适用于 Linux 下的高并发场景。 - 优点:封装良好,使用方便,能够提高开发效率。
- 缺点:引入了额外的依赖,性能调优相对不够灵活。
8.2.5. Reactor 模式
Reactor 模式是 I/O 多路复用的一种常见实现模式。它通过注册 I/O 事件,将事件分发给事件处理器。
- 典型实现:使用
epoll
或select
监听事件,再结合事件处理回调函数进行处理。 - 优点:能够较好地处理大量并发连接,灵活性高。
- 缺点:编写和理解较为复杂,需要维护事件循环和回调函数。
8.2.6. Proactor 模式
Proactor 模式是异步 I/O 的常见实现,区别于 Reactor,Proactor 是 I/O 操作完成后再进行回调。
- 典型实现:Windows 上的
IOCP
就是 Proactor 模式的实现。 - 优点:异步操作更加彻底,I/O 操作由操作系统处理,减少了用户态的干预。
- 缺点:实现相对复杂,调试较难。
8.2.7. 协程(Coroutine)
协程是一种轻量级的线程,能够在用户态进行切换。使用协程可以避免线程切换的开销,同时实现高并发。可以结合 I/O 多路复用技术,如 epoll
,来实现高效的协程并发。
- 典型框架:如
Boost.Asio
支持协程、libgo
协程库等。 - 优点:切换开销小,性能高,代码风格接近同步编程,易于理解。
- 缺点:调试较复杂,尤其是在上下文切换时容易出现问题。
8.3. 使用epoll改进的例子
使用 epoll
实现一对一聊天的服务端,可以大大提高服务器的并发处理能力,相比于传统的多线程或 select
,epoll
更适合处理大量客户端的连接,尤其是在高并发场景下。
8.3.1. Epoll 基本工作流程
- 创建 epoll 文件描述符:使用
epoll_create
创建 epoll 实例。 - 注册事件:通过
epoll_ctl
将套接字添加到 epoll 实例中,并设置要监听的事件(如EPOLLIN
表示有数据可读)。 - 等待事件:使用
epoll_wait
等待事件发生。 - 处理事件:一旦事件发生,处理相应的客户端读写操作。
8.3.2. 服务端的实现思路
- 启动 epoll 实例,监听客户端的连接请求。
- 当有新的客户端连接时,将其注册到 epoll 实例中。
- 维护客户端配对关系:服务端为每对客户端建立配对表。
- 消息的收发和转发:当接收到某个客户端的消息时,通过配对表找到对应的目标客户端,并将消息转发过去。
8.3.3. 服务端代码示例(使用 epoll 实现)
#include <iostream>
#include <string>
#include <cstring>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <sys/epoll.h>
#include <map>#define PORT 8080
#define BUFFER_SIZE 1024
#define MAX_EVENTS 10std::map<int, int> client_map; // 客户端配对表int main() {int server_socket, epoll_fd;struct sockaddr_in server_addr;// 创建服务器套接字server_socket = socket(AF_INET, SOCK_STREAM, 0);if (server_socket == -1) {std::cerr << "创建服务器套接字失败。" << std::endl;return -1;}// 设置地址复用,避免端口占用问题int opt = 1;setsockopt(server_socket, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));// 初始化服务器地址server_addr.sin_family = AF_INET;server_addr.sin_addr.s_addr = INADDR_ANY;server_addr.sin_port = htons(PORT);// 绑定地址到服务器套接字if (bind(server_socket, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {std::cerr << "绑定失败。" << std::endl;return -1;}// 监听端口if (listen(server_socket, 10) < 0) {std::cerr << "监听失败。" << std::endl;return -1;}// 创建 epoll 实例epoll_fd = epoll_create1(0);if (epoll_fd == -1) {std::cerr << "创建 epoll 实例失败。" << std::endl;return -1;}// 将服务器套接字添加到 epoll 实例中,监听连接请求struct epoll_event event;event.events = EPOLLIN;event.data.fd = server_socket;epoll_ctl(epoll_fd, EPOLL_CTL_ADD, server_socket, &event);std::cout << "服务器启动,等待客户端连接..." << std::endl;struct epoll_event events[MAX_EVENTS];while (true) {int event_count = epoll_wait(epoll_fd, events, MAX_EVENTS, -1); // 无限等待事件for (int i = 0; i < event_count; ++i) {if (events[i].data.fd == server_socket) {// 处理新的客户端连接struct sockaddr_in client_addr;socklen_t client_len = sizeof(client_addr);int client_socket = accept(server_socket, (struct sockaddr*)&client_addr, &client_len);if (client_socket == -1) {std::cerr << "接受客户端连接失败。" << std::endl;continue;}std::cout << "新客户端连接: " << inet_ntoa(client_addr.sin_addr) << std::endl;// 将新客户端套接字添加到 epoll 监听中event.events = EPOLLIN;event.data.fd = client_socket;epoll_ctl(epoll_fd, EPOLL_CTL_ADD, client_socket, &event);// 客户端配对处理if (client_map.empty()) {client_map[client_socket] = -1; // 第一个客户端,暂时没有配对} else {for (auto& pair : client_map) {if (pair.second == -1) {client_map[client_socket] = pair.first; // 配对client_map[pair.first] = client_socket;break;}}}} else {// 处理客户端的消息收发int client_socket = events[i].data.fd;char buffer[BUFFER_SIZE];memset(buffer, 0, sizeof(buffer));ssize_t bytes_received = recv(client_socket, buffer, sizeof(buffer), 0);if (bytes_received <= 0) {// 客户端断开连接std::cout << "客户端断开连接。" << std::endl;epoll_ctl(epoll_fd, EPOLL_CTL_DEL, client_socket, NULL);close(client_socket);continue;}std::cout << "收到消息: " << buffer << std::endl;// 转发消息给配对的客户端int target_client = client_map[client_socket];if (target_client != -1) {send(target_client, buffer, strlen(buffer), 0);} else {std::cout << "目标客户端未连接。" << std::endl;}}}}close(server_socket);return 0;
}
8.3.4. 实现说明
- Epoll 创建:通过
epoll_create1
创建一个 epoll 实例,epoll_ctl
用于将服务器套接字添加到 epoll 监听中。 - 事件处理:使用
epoll_wait
等待客户端连接事件和消息事件。一旦有事件发生,处理新连接或消息收发。 - 客户端配对:与之前的多线程版本类似,使用
client_map
维护客户端之间的配对关系。
8.3.5. 优势
- 高并发支持:
epoll
适合大量客户端并发连接,比select
或多线程处理效率更高。 - 事件驱动:基于事件通知的机制,而不是轮询,降低了 CPU 使用率。
- 资源高效:无需为每个连接创建独立线程,减少了上下文切换的开销。