目录
[socket() - 创建套接字,这是网络通信的第一步。](#socket() - 创建套接字,这是网络通信的第一步。)
[bind() - 绑定地址](#bind() - 绑定地址)
[listen() - 开始监听](#listen() - 开始监听)
[send() - 发送数据](#send() - 发送数据)
[recv() - 接收数据](#recv() - 接收数据)
[select() - I/O 多路复用](#select() - I/O 多路复用)
一、引言
本文目标:掌握单线程、多线程、select搭建TCP服务器的方法。
关键技术点:
- socket / bind / listen / accept
- recv / send
- select
二、关键函数
在深入每个函数之前,先理解它们在构建一个 TCP 服务器时的标准流程:
- socket(): 创建一个通信端点(套接字)。
- bind(): 将这个套接字与一个具体的 IP 地址和端口号绑定。
- listen(): 将套接字设置为被动监听模式,准备接受连接。
- accept(): 从已建立的连接队列中取出一个连接,创建一个新的套接字用于与该客户端通信。
- send() / recv(): 使用新的通信套接字与客户端进行数据收发。
- close(): 通信结束后,关闭套接字。
select()函数则是一种更高级的机制,用于在一个线程中同时管理多个套接字,实现 I/O 多路复用。
socket() - 创建套接字,这是网络通信的第一步。
- 作用: 在内核中分配资源,创建一个新的套接字,并返回一个文件描述符。
- 函数原型:
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
- 参数 :
domain: 指定通信协议族。AF_INET: IPv4 协议(最常用)。AF_INET6: IPv6 协议。AF_UNIX: 本地通信(同一台机器上的进程间通信)。
type: 指定套接字类型。SOCK_STREAM: 流式套接字,提供可靠的、面向连接的通信(如 TCP)。SOCK_DGRAM: 数据报套接字,提供无连接的、不可靠的通信(如 UDP)。
protocol: 指定具体的协议。通常设为0,表示根据domain和type自动选择默认协议(如SOCK_STREAM对应 TCP)。
- 返回值 :
- 成功 : 返回一个非负整数,即套接字文件描述符。
- 失败 : 返回
-1,并设置errno。
bind() - 绑定地址
服务器需要将创建的套接字与一个众所周知的地址(IP + 端口)关联起来,以便客户端能够找到它。
- 作用: 将一个套接字与一个本地 IP 地址和端口号绑定。
- 函数原型:
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 参数 :
sockfd:socket()函数返回的套接字文件描述符。addr: 指向一个地址结构体的指针,包含要绑定的 IP 地址和端口号。对于 IPv4,通常使用struct sockaddr_in。addrlen: 地址结构体的大小,通常是sizeof(struct sockaddr_in)。
- 返回值 :
- 成功 : 返回
0。 - 失败 : 返回
-1,并设置errno。
- 成功 : 返回
listen() - 开始监听
绑定地址后,服务器需要告诉内核它准备接受连接请求。
- 作用 : 将一个已绑定的套接字转换为被动监听状态,并创建一个连接请求队列。
- 函数原型:
#include <sys/socket.h>
int listen(int sockfd, int backlog);
- 参数 :
sockfd:bind()成功后的套接字文件描述符。backlog: 指定连接请求队列的最大长度。当队列满时,新的连接请求会被拒绝。
- 返回值 :
- 成功 : 返回
0。 - 失败 : 返回
-1,并设置errno。
- 成功 : 返回
accept() - 接受连接
服务器调用 listen() 后,客户端就可以发起连接。accept() 用于从已完成连接的队列中取出一个。
- 作用 : 从监听套接字的已完成连接队列中取出第一个连接,并创建一个新的套接字用于与该客户端通信。
- 函数原型:
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- 参数 :
sockfd:listen()成功后的监听套接字文件描述符。addr: 传出参数 。用于获取客户端的地址信息(IP 和端口)。如果不需要,可以传NULL。addrlen: 传入传出参数 。调用前,传入addr指向的缓冲区大小;调用后,返回客户端地址结构体的实际长度。
- 返回值 :
- 成功 : 返回一个新的套接字文件描述符,专门用于和当前这个客户端通信。
- 失败 : 返回
-1,并设置errno。
send() - 发送数据
- 作用: 通过一个已连接的套接字向对端发送数据。
- 函数原型:
#include <sys/socket.h>
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
- 参数 :
sockfd: 已建立连接的套接字文件描述符(accept()的返回值或客户端的套接字)。buf: 指向要发送的数据缓冲区的指针。len: 要发送的数据长度(字节数)。flags: 控制发送行为的标志,通常设为0。常用标志有MSG_OOB(发送带外数据)、MSG_DONTWAIT(非阻塞发送)等。
- 返回值 :
- 成功 : 返回实际发送的字节数 。这个值可能小于
len,尤其是在非阻塞模式下。 - 失败 : 返回
-1,并设置errno。
- 成功 : 返回实际发送的字节数 。这个值可能小于
recv() - 接收数据
- 作用: 从一个已连接的套接字接收来自对端的数据。
- 函数原型:
#include <sys/socket.h>
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
- 参数 :
sockfd: 已建立连接的套接字文件描述符。buf: 指向用于存储接收数据的缓冲区的指针。len: 缓冲区的最大长度。flags: 控制接收行为的标志,通常设为0。常用标志有MSG_PEEK(窥看数据但不从队列中删除)、MSG_WAITALL(等待所有请求的数据)等。
- 返回值 :
- > 0 : 返回实际接收到的字节数。
- = 0 : 表示对端已正常关闭连接(发送了 FIN 包)。
- = -1 : 表示发生错误,并设置
errno。
select() - I/O 多路复用
当一个服务器需要同时处理成百上千个客户端连接时,为每个连接创建一个线程/进程开销巨大。select() 允许一个进程监视多个文件描述符,一旦有描述符就绪(可读、可写或异常),就会通知程序进行相应操作。
- 作用: 监视多个文件描述符的状态变化(可读、可写、异常),是一种 I/O 多路复用机制。
- 函数原型:
#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,则一直阻塞直到有事件发生。
- 返回值 :
- > 0 : 返回就绪的文件描述符总数。
- = 0 : 表示超时,没有任何描述符就绪。
- = -1 : 表示发生错误,并设置
errno。
三、单线程模型
cpp
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#define QUEUE_LENGTH 5
#define BUFFER_SIZE 128
#define PORT 8080
int main() {
int server_fd = socket(AF_INET, SOCK_STREAM, 0);
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("绑定失败!\n");
return -1;
}
if (listen(server_fd, QUEUE_LENGTH) < 0) {
perror("监听失败!\n");
return -1;
}
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[BUFFER_SIZE] = { 0 };
while (1) {
int client_fd = accept(server_fd,(struct sockaddr*)&client_addr, &client_len);
if (client_fd == -1) {
perror("接受连接失败");
continue;
}
ssize_t ret = recv(client_fd, buffer, BUFFER_SIZE, 0);
if (ret <= 0) {
perror("客户端断开连接!\n");
close(client_fd);
continue;
}
printf("client: %s\n", buffer);
send(client_fd, buffer, sizeof(buffer), 0);
close(client_fd);
memset(buffer, 0, sizeof(buffer));
}
return 0;
}

可以看到,一发一收之后就断开连接了,如果还想发送数据,就必须重新建立连接。频繁的建连,三次握手四次挥手带来的开销还是挺大的。
四、多线程模型
cpp
#include <stdio.h>
#include <netinet/in.h>
#include <unistd.h>
#include <sys/socket.h>
#include <pthread.h>
#include <stdlib.h>
typedef struct {
int client_fd;
struct sockaddr_in addr;
} ClientInfo;
void* handle_client(void* arg) {
ClientInfo* client = (ClientInfo*)arg;
int client_fd = client->client_fd;
char buffer[1024] = { 0 };
printf("新线程已创建\n");
while (1) {
ssize_t len = recv(client_fd, buffer, sizeof(buffer) - 1, 0);
if (len <= 0) {
printf("客户端断开连接\n");
break;
}
buffer[len] = '\0';
printf("收到客户端数据:%s\n",buffer);
send(client_fd, buffer, sizeof(buffer), 0);
}
close(client_fd);
free(client);
pthread_exit(NULL);
}
int main() {
int listen_fd;
socklen_t addr_len;
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == -1) {
perror("socket 创建失败!");
return -1;
}
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(8080);
if (bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("绑定失败");
close(listen_fd);
return -1;
}
if (listen(listen_fd, 5) < 0) {
perror("监听失败");
close(listen_fd);
return -1;
}
while (1) {
ClientInfo* client = malloc(sizeof(ClientInfo));
if (!client) {
perror("malloc失败");
continue;
}
if ((client->client_fd = accept(listen_fd, (struct sockaddr*)&client->addr, &addr_len)) < 0) {
perror("accept失败");
free(client);
continue;
}
pthread_t thread_id;
if (pthread_create(&thread_id, NULL, handle_client, (void*)client) != 0) {
perror("创建线程失败");
close(client->client_fd);
free(client);
continue;
}
pthread_detach(thread_id);
}
close(listen_fd);
return 0;
}

每来一个连接,就开一个专门的线程来处理,这样就可以随便发数据了。但是,如果连接数量比较多的话,那么会开很多个线程,线程的切换以及每个线程独有的资源加载一块,也是一个大的开销。而且,线程是占用内存的,内存的容量有限,也就是意味着线程的数量也有限。
五、select模型
cpp
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/select.h>
#include <asm-generic/socket.h>
#define MAX_CLIENT 10
#define PORT 8080
int main() {
int listen_fd;
int opt = 1;
struct sockaddr_in addr;
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd == -1) {
perror("socket 创建失败");
return -1;
}
if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR |
SO_REUSEPORT, &opt, sizeof(opt)) == -1) {
perror("socket属性设置失败");
return -1;
}
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = INADDR_ANY;
addr.sin_port = htons(PORT);
if (bind(listen_fd, (struct sockaddr*)&addr, sizeof(addr)) < 0) {
perror("绑定失败");
return -1;
}
if (listen(listen_fd, 5) < 0) {
perror("监听失败");
return -1;
}
struct sockaddr_in client_addr;
socklen_t addr_len = sizeof(client_addr);
int client_fds[MAX_CLIENT] = { 0 };
fd_set read_fds;
char buffer[1024] = { 0 };
while (1) {
FD_ZERO(&read_fds);
FD_SET(listen_fd, &read_fds);
int max_fd = listen_fd;
for (int i = 0; i < MAX_CLIENT; i++) {
int fd = client_fds[i];
if (fd > 0) FD_SET(fd, &read_fds);
if (fd > max_fd) max_fd = fd;
}
int activity = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
// 有新的连接到来
if (FD_ISSET(listen_fd, &read_fds)) {
int client_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &addr_len);
if (client_fd == -1) {
perror("accept 失败");
continue;
}
printf("有新客户端 %d 连接\n", client_fd);
for (int i = 0; i < MAX_CLIENT; i++) {
if (client_fds[i] == 0) {
client_fds[i] = client_fd;
break;
}
}
}
// 遍历所有客户端判断是否有可读事件
for (int i = 0; i < MAX_CLIENT; i++) {
int fd = client_fds[i];
if (FD_ISSET(fd, &read_fds)) {
int read_num = recv(fd, buffer, sizeof(buffer) - 1, 0);
if (read_num == 0) {
printf("客户端 %d 断开连接\n", fd);
close(fd);
client_fds[i] = 0;
}else {
buffer[read_num] = '\0';
printf("收到客户端 %d 数据: %s \n", fd, buffer);
send(fd, buffer, sizeof(buffer), 0);
memset(buffer, 0, sizeof(buffer));
}
}
}
}
return 0;
}

select的IO多路复用机制,使得一个线程就可以监听多个连接。
不过,可以看到,每一次都要重新传入监听的文件描述符集合,这是因为select内部会修改集合。没有触发读事件的的文件描述符对应的位会被清零。
六、结语
欢迎批评指正!
完