TCP协议详解
TCP协议详解
在网络编程中,TCP(传输控制协议)是当之无愧的核心——它的面向连接和可靠传输特性,支撑了绝大多数互联网应用(比如浏览网页、即时通讯、文件传输)。如果你未来需要复习TCP相关知识,这篇内容会从编程实现到协议底层,带你一步步吃透TCP的所有关键知识点。文章会穿插代码示例和通俗比喻,偶尔也会抛出问题让你主动思考,帮你加深记忆。
一、TCP协议的核心特性
在写代码之前,我们得先明确TCP的核心优势——这些优势决定了它的编程逻辑和使用场景:
- 面向连接:通信前必须先建立连接(类似打电话,先拨号通了再说话),通信后要释放连接;
- 面向字节流:数据以字节为单位连续传输,没有“报文边界”(类似读文件,一次读多少由你决定);
- 可靠传输:通过确认、重传、流量控制、拥塞控制等机制,保证数据不丢、不重、按序到达;
- 全双工通信:通信双方可以同时发送和接收数据(类似两个人同时说话,互不干扰)。
对比UDP(无连接、不可靠),TCP更适合对可靠性要求高的场景(比如登录验证、文件下载)。接下来,我们从TCP服务器的编程实现开始,逐步深入。
二、TCP服务器基础构建
要实现一个TCP服务器,第一步是搭建基础框架——创建套接字、绑定地址、设置监听。这三步就像开一家餐厅:先租个店面(创建套接字),挂上门牌号(绑定IP和端口),然后打开门等客人(监听连接)。
2.1 关键步骤与代码实现
步骤1:创建套接字(socket函数)
套接字(socket)是TCP通信的“入口”,用socket
函数创建,参数需指定协议家族(IPv4用AF_INET
)、套接字类型(TCP用SOCK_STREAM
,流式套接字)、协议号(默认0,由系统自动匹配)。
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>int main() {// 1. 创建TCP套接字(流式套接字)int listen_fd = socket(AF_INET, SOCK_STREAM, 0);if (listen_fd < 0) {perror("socket error");return -1;}printf("套接字创建成功,listen_fd: %d\n", listen_fd); // 通常是3(0、1、2是标准输入/输出/错误)
步骤2:绑定IP和端口(bind函数)
创建套接字后,需要给它绑定一个IP地址和端口号(门牌号),让客户端能找到它。注意:
- IP地址用
0.0.0.0
表示“监听所有网卡的IP”(云服务器推荐用这个,避免绑定公网IP失败); - 端口号需用
htons
函数转换为网络字节序(大端序,因为不同主机字节序可能不同,网络统一用大端)。
// 2. 填充服务器地址结构struct sockaddr_in server_addr;memset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET; // IPv4server_addr.sin_port = htons(8888); // 端口号:8888(1024以下是系统端口,推荐用1024以上)server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定所有网卡IP(0.0.0.0)// 绑定地址int ret = bind(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));if (ret < 0) {perror("bind error");close(listen_fd); // 失败要关闭套接字,避免资源泄漏return -1;}printf("绑定成功,端口:8888\n");
步骤3:设置监听(listen函数)
TCP是面向连接的协议,服务器必须调用listen
函数进入“监听状态”,等待客户端连接。listen
的第二个参数backlog
表示全连接队列的长度(客户端已完成三次握手但服务器未处理的连接数,默认设5~10即可,具体含义后面讲TCP协议时再细说)。
// 3. 设置监听int backlog = 5;ret = listen(listen_fd, backlog);if (ret < 0) {perror("listen error");close(listen_fd);return -1;}printf("监听中,等待客户端连接...\n");
2.2 思考:为什么UDP不需要listen?
因为UDP是无连接协议——客户端不需要“连接”就能发数据,服务器也不需要“等连接”,直接用recvfrom
收数据即可。而TCP必须先建立连接,所以服务器需要监听连接请求。
三、accept接口与套接字分工
监听状态的服务器,需要用accept
函数获取客户端的连接。这里有个关键概念:监听套接字和通信套接字的分工——这是TCP服务器的核心设计,用一个通俗比喻就能理解:
假设你开了一家鱼庄:
- “拉客员张三”(监听套接字
listen_fd
):只负责在门口等客人,接到客人后,把客人交给“服务员李四”;- “服务员李四”(通信套接字
conn_fd
):只负责给客人上菜、服务,不参与拉客;- 张三可以不断拉新客人,每个客人对应一个李四——监听套接字只有一个,通信套接字可以有多个。
3.1 accept函数:获取新连接
accept
函数从监听队列中取出一个已完成三次握手的连接,返回一个新的通信套接字(conn_fd
),后续与客户端的读写都通过这个套接字。参数中,client_addr
用来存储客户端的IP和端口(输出型参数)。
// 4. 用accept获取新连接struct sockaddr_in client_addr;socklen_t client_addr_len = sizeof(client_addr);// accept会阻塞,直到有客户端连接(默认阻塞模式)int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_addr_len);if (conn_fd < 0) {perror("accept error");close(listen_fd);return -1;}// 解析客户端IP和端口(网络字节序转主机字节序)char client_ip[INET_ADDRSTRLEN]; // 存储点分十进制IP的缓冲区inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip)); // 网络IP转字符串uint16_t client_port = ntohs(client_addr.sin_port); // 网络端口转主机端口printf("新连接到来:IP=%s, 端口=%d, conn_fd=%d\n", client_ip, client_port, conn_fd);
3.2 关键注意点
- 监听套接字 vs 通信套接字:
- 监听套接字(
listen_fd
):全程只负责监听,不会参与数据读写; - 通信套接字(
conn_fd
):每个客户端对应一个,负责与该客户端的读写,客户端断开后要关闭。
- 监听套接字(
- 字节序转换:
- 客户端的IP(
client_addr.sin_addr
)和端口(client_addr.sin_port
)是网络字节序,需要用inet_ntop
(IP转字符串)和ntohs
(端口转主机序)转换后才能正常显示; - 数据内容(比如客户端发的“hello”)不需要手动转换——TCP会自动处理字节序,我们只需要管IP和端口的转换。
- 客户端的IP(
四、回显服务(Echo Server)
有了通信套接字,我们就可以实现最简单的TCP服务——回显服务:服务器读取客户端发的消息,原样回传给客户端。这能直观体现TCP“面向字节流”的特性:网络读写和文件读写的接口完全一样(用read
/write
)。
4.1 回显服务代码实现
// 5. 实现回显服务:读取客户端消息,原样回传char buffer[4096]; // 缓冲区,存储读取的消息while (1) {// 读取客户端消息(read会阻塞,直到有数据到来)ssize_t n = read(conn_fd, buffer, sizeof(buffer)-1); // 留1个字节存字符串结束符'\0'if (n < 0) {perror("read error");break; // 读取失败,退出循环} else if (n == 0) {printf("客户端[%s:%d]主动断开连接\n", client_ip, client_port);break; // 客户端关闭连接,read返回0,退出循环}// 给读取的字节流加字符串结束符,避免乱码buffer[n] = '\0';printf("收到客户端[%s:%d]消息:%s\n", client_ip, client_port, buffer);// 回显消息给客户端(write写数据到通信套接字)n = write(conn_fd, buffer, strlen(buffer));if (n < 0) {perror("write error");break;}printf("回显消息给客户端[%s:%d]:%s\n", client_ip, client_port, buffer);}// 6. 关闭套接字(先关通信套接字,再关监听套接字)close(conn_fd);close(listen_fd);printf("服务器关闭\n");return 0;
}
4.2 思考:为什么用read/write?TCP的字节流特性怎么体现?
因为TCP把网络连接抽象成了“文件”——就像读本地文件用read
,写文件用write
一样,读网络数据也用read
,写网络数据也用write
。字节流特性体现在:
- 数据没有固定的“报文大小”,比如客户端发“hello”,服务器可以一次读5字节,也可以分两次读(先读2字节“he”,再读3字节“llo”);
- 只要连接没断,
read
会一直读数据,直到客户端关闭连接(返回0)或出错(返回-1)。
五、TCP客户端编程:主动发起连接
TCP客户端的逻辑比服务器简单——不需要监听,只需要“主动发起连接”。核心是connect
函数,以及一个关键特性:客户端不需要显式绑定端口。
5.1 客户端为什么不需要显式绑定端口?
想象一下:你的手机上有多个APP(微信、淘宝、抖音),每个APP都需要用TCP连接服务器。如果每个APP都要手动绑定一个端口,很容易出现“端口冲突”(两个APP绑同一个端口,一个能启动,另一个就失败)。
所以操作系统会自动给客户端分配一个临时端口(通常是1024~65535之间的未使用端口),客户端只需要关心“连接哪个服务器”(服务器的IP和端口是固定的),不需要管自己的端口是什么。
5.2 客户端代码实现
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>int main(int argc, char* argv[]) {// 检查命令行参数:./tcp_client 服务器IP 服务器端口if (argc != 3) {fprintf(stderr, "用法:%s <server_ip> <server_port>\n", argv[0]);exit(1);}const char* server_ip = argv[1];uint16_t server_port = atoi(argv[2]); // 端口号转整数// 1. 创建TCP套接字(和服务器一样,用SOCK_STREAM)int sock_fd = socket(AF_INET, SOCK_STREAM, 0);if (sock_fd < 0) {perror("socket error");exit(1);}// 2. 填充服务器地址结构(客户端不需要绑定自己的地址)struct sockaddr_in server_addr;memset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_port = htons(server_port); // 服务器端口转网络字节序// 把字符串IP转成网络字节序IPif (inet_pton(AF_INET, server_ip, &server_addr.sin_addr) <= 0) {perror("inet_pton error(IP格式错误)");close(sock_fd);exit(1);}// 3. 主动发起连接(connect会阻塞,直到连接建立或失败)int ret = connect(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));if (ret < 0) {perror("connect error(连接失败,检查服务器IP和端口)");close(sock_fd);exit(1);}printf("连接服务器[%s:%d]成功!\n", server_ip, server_port);// 4. 与服务器通信:输入消息,发送给服务器,接收回显char buffer[4096];while (1) {printf("请输入要发送的消息(退出按Ctrl+C):");fflush(stdout); // 刷新缓冲区,确保提示语显示// 读取用户输入(从标准输入读)if (fgets(buffer, sizeof(buffer), stdin) == NULL) {perror("fgets error");break;}// 去掉fgets读取的换行符(fgets会把回车也读进来)size_t len = strlen(buffer);if (len > 0 && buffer[len-1] == '\n') {buffer[len-1] = '\0';}// 发送消息给服务器ret = write(sock_fd, buffer, strlen(buffer));if (ret < 0) {perror("write error");break;}// 接收服务器的回显消息len = read(sock_fd, buffer, sizeof(buffer)-1);if (len < 0) {perror("read error");break;} else if (len == 0) {printf("服务器断开连接\n");break;}buffer[len] = '\0';printf("服务器回显:%s\n", buffer);}// 5. 关闭套接字close(sock_fd);printf("客户端关闭\n");return 0;
}
5.3 客户端与服务器的区别
对比项 | TCP服务器 | TCP客户端 |
---|---|---|
核心动作 | 监听连接(listen)、获取连接(accept) | 发起连接(connect) |
套接字数量 | 监听套接字1个 + 通信套接字N个 | 只有1个通信套接字 |
端口绑定 | 必须显式绑定(固定端口) | 不需要,操作系统自动分配临时端口 |
角色 | 被动接收连接 | 主动发起连接 |
六、单进程服务器的局限性与并发模型
前面写的服务器是“单进程”的——同一时间只能处理一个客户端。比如客户端A连接后,服务器会卡在read
循环里处理A的消息,此时客户端B再连接,会被堵在accept
队列里,直到A断开连接。这就像“餐厅只有一张桌子”,客人来了只能排队,效率极低。
要解决这个问题,需要引入并发模型——让服务器能同时处理多个客户端。常见的并发模型有三种:多进程、多线程、线程池。
6.1 多进程并发模型
核心思路:每获取一个新连接(accept
返回conn_fd
),就fork
一个子进程,让子进程处理这个客户端的通信,父进程继续accept
新连接。
多进程服务器代码
#include <sys/wait.h>
#include <signal.h>// 信号处理函数:忽略SIGCHLD信号,避免僵尸进程(简化版)
void sigchld_handler(int sig) {// 非阻塞回收所有僵尸子进程while (waitpid(-1, NULL, WNOHANG) > 0);
}int main() {// ... 前面的创建套接字、绑定、监听代码和之前一样 ...// 注册SIGCHLD信号处理函数,避免僵尸进程signal(SIGCHLD, sigchld_handler);while (1) {// 获取新连接int conn_fd = accept(listen_fd, ...);if (conn_fd < 0) {perror("accept error");continue; // accept失败也不退出,继续等下一个连接}// fork创建子进程pid_t pid = fork();if (pid < 0) {perror("fork error");close(conn_fd); // fork失败,关闭通信套接字continue;} else if (pid == 0) {// 子进程:处理客户端通信,不需要监听套接字,先关闭close(listen_fd);// 回显服务逻辑(和之前的read/write循环一样)char buffer[4096];while (1) {ssize_t n = read(conn_fd, buffer, sizeof(buffer)-1);if (n <= 0) {if (n < 0) perror("read error");else printf("客户端断开\n");break;}buffer[n] = '\0';printf("子进程[%d]收到消息:%s\n", getpid(), buffer);write(conn_fd, buffer, strlen(buffer));}// 子进程处理完,关闭通信套接字,退出close(conn_fd);exit(0);} else {// 父进程:继续accept新连接,不需要通信套接字,先关闭close(conn_fd);}}close(listen_fd);return 0;
}
关键细节:避免僵尸进程
fork
后,如果子进程先退出,父进程没回收它的资源(比如PID、退出状态),子进程会变成僵尸进程(占用系统资源,无法被调度)。解决办法有两种:
- 注册SIGCHLD信号处理函数:子进程退出时会给父进程发
SIGCHLD
信号,父进程在信号处理函数中用waitpid(-1, NULL, WNOHANG)
非阻塞回收所有僵尸子进程; - 二次fork技巧:父进程fork子进程A,子进程A再fork子进程B(孙子进程),然后子进程A立即退出。孙子进程的父进程会变成
init
进程(PID=1),init
会自动回收孙子进程的资源,不会产生僵尸进程。
6.2 多线程并发模型:比多进程更轻量
多进程的缺点是创建成本高(每个进程有独立的地址空间、PCB),而线程是进程内的执行单元,共享进程的地址空间,创建和切换成本更低。
核心思路:每获取一个新连接,就创建一个线程,让线程处理客户端通信,主线程继续accept
。注意线程要设置为分离状态(pthread_detach
),避免主线程等待线程退出。
多线程服务器代码(关键部分)
#include <pthread.h>// 线程参数:需要传递客户端的IP、端口、通信套接字
typedef struct {int conn_fd;char client_ip[INET_ADDRSTRLEN];uint16_t client_port;
} ThreadArgs;// 线程函数:处理客户端通信(必须是void*返回值,void*参数)
void* handle_client(void* args) {ThreadArgs* targs = (ThreadArgs*)args;int conn_fd = targs->conn_fd;char* client_ip = targs->client_ip;uint16_t client_port = targs->client_port;// 设置线程为分离状态,避免主线程pthread_joinpthread_detach(pthread_self());free(args); // 释放参数内存(主线程中malloc的)// 回显服务逻辑(和之前一样)char buffer[4096];while (1) {ssize_t n = read(conn_fd, buffer, sizeof(buffer)-1);if (n <= 0) {if (n < 0) perror("read error");else printf("客户端[%s:%d]断开\n", client_ip, client_port);break;}buffer[n] = '\0';printf("线程[%lu]收到消息:%s\n", pthread_self(), buffer);write(conn_fd, buffer, strlen(buffer));}close(conn_fd);return NULL;
}int main() {// ... 前面的创建套接字、绑定、监听代码 ...while (1) {struct sockaddr_in client_addr;socklen_t client_len = sizeof(client_addr);int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);if (conn_fd < 0) {perror("accept error");continue;}// 解析客户端IP和端口char client_ip[INET_ADDRSTRLEN];inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));uint16_t client_port = ntohs(client_addr.sin_port);// 分配线程参数(堆内存,避免栈内存被覆盖)ThreadArgs* args = (ThreadArgs*)malloc(sizeof(ThreadArgs));args->conn_fd = conn_fd;strcpy(args->client_ip, client_ip);args->client_port = client_port;// 创建线程pthread_t tid;int ret = pthread_create(&tid, NULL, handle_client, (void*)args);if (ret != 0) {perror("pthread_create error");free(args);close(conn_fd);continue;}printf("创建线程[%lu]处理客户端[%s:%d]\n", tid, client_ip, client_port);}close(listen_fd);return 0;
}
编译注意:链接线程库
编译多线程代码时,需要加-lpthread
选项,告诉编译器链接 pthread 库:
gcc tcp_server_thread.c -o tcp_server_thread -lpthread
6.3 线程池并发模型:解决线程创建频繁的问题
多线程模型的缺点是:如果客户端连接特别多(比如每秒1000个),会频繁创建和销毁线程,导致系统开销过大。线程池模型的思路是提前创建一批线程(比如10个),用一个“任务队列”存储客户端连接,线程从队列中取任务处理,避免频繁创建线程。
核心是生产者-消费者模型:
- 生产者(主线程):
accept
新连接,把连接封装成“任务”,加入任务队列; - 消费者(线程池中的线程):循环从任务队列中取任务,处理客户端通信;
- 同步互斥:用互斥锁(
pthread_mutex_t
)保护任务队列,用条件变量(pthread_cond_t
)实现线程等待/唤醒(队列空时线程等待,有任务时唤醒线程)。
线程池核心代码(简化版)
#include <pthread.h>
#include <queue>
using namespace std;// 任务结构体:每个任务对应一个客户端连接
typedef struct {int conn_fd;char client_ip[INET_ADDRSTRLEN];uint16_t client_port;
} Task;// 线程池类
class ThreadPool {
public:ThreadPool(int thread_num) : thread_num_(thread_num), stop_(false) {// 初始化互斥锁和条件变量pthread_mutex_init(&mutex_, NULL);pthread_cond_init(&cond_, NULL);// 创建thread_num个线程for (int i = 0; i < thread_num_; ++i) {pthread_create(&threads_[i], NULL, worker, this);}}~ThreadPool() {// 停止线程池stop_ = true;pthread_cond_broadcast(&cond_); // 唤醒所有等待的线程for (int i = 0; i < thread_num_; ++i) {pthread_join(threads_[i], NULL); // 等待线程退出}// 销毁互斥锁和条件变量pthread_mutex_destroy(&mutex_);pthread_cond_destroy(&cond_);}// 添加任务到队列void add_task(const Task& task) {pthread_mutex_lock(&mutex_);task_queue_.push(task);pthread_mutex_unlock(&mutex_);pthread_cond_signal(&cond_); // 唤醒一个等待的线程}private:// 线程函数:消费者逻辑static void* worker(void* arg) {ThreadPool* pool = (ThreadPool*)arg;while (!pool->stop_) {pthread_mutex_lock(&pool->mutex_);// 队列空时,等待条件变量while (pool->task_queue_.empty() && !pool->stop_) {pthread_cond_wait(&pool->cond_, &pool->mutex_);}// 线程池停止,退出循环if (pool->stop_) {pthread_mutex_unlock(&pool->mutex_);break;}// 取出任务Task task = pool->task_queue_.front();pool->task_queue_.pop();pthread_mutex_unlock(&pool->mutex_);// 处理任务(回显服务)pool->handle_task(task);}return NULL;}// 处理单个任务void handle_task(const Task& task) {int conn_fd = task.conn_fd;const char* client_ip = task.client_ip;uint16_t client_port = task.client_port;char buffer[4096];while (1) {ssize_t n = read(conn_fd, buffer, sizeof(buffer)-1);if (n <= 0) {if (n < 0) perror("read error");else printf("客户端[%s:%d]断开\n", client_ip, client_port);break;}buffer[n] = '\0';printf("线程池处理客户端[%s:%d]消息:%s\n", client_ip, client_port, buffer);write(conn_fd, buffer, strlen(buffer));}close(conn_fd);}private:int thread_num_; // 线程数量pthread_t threads_[100]; // 线程数组(假设最多100个线程)queue<Task> task_queue_; // 任务队列pthread_mutex_t mutex_; // 保护任务队列的互斥锁pthread_cond_t cond_; // 条件变量,用于线程等待/唤醒bool stop_; // 线程池停止标志
};// 主线程逻辑
int main() {// ... 前面的创建套接字、绑定、监听代码 ...ThreadPool pool(5); // 创建5个线程的线程池printf("线程池初始化完成,等待客户端连接...\n");while (1) {struct sockaddr_in client_addr;socklen_t client_len = sizeof(client_addr);int conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);if (conn_fd < 0) {perror("accept error");continue;}// 解析客户端信息char client_ip[INET_ADDRSTRLEN];inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));uint16_t client_port = ntohs(client_addr.sin_port);// 封装任务,添加到线程池Task task;task.conn_fd = conn_fd;strcpy(task.client_ip, client_ip);task.client_port = client_port;pool.add_task(task);}close(listen_fd);return 0;
}
6.4 三种并发模型对比
模型 | 优点 | 缺点 | 适用场景 |
---|---|---|---|
多进程 | 稳定性高(进程独立,一个崩溃不影响其他) | 创建/切换成本高,资源占用多 | 客户端连接数少、对稳定性要求高的场景 |
多线程 | 创建/切换成本低,资源占用少 | 线程共享地址空间,一个崩溃影响所有 | 客户端连接数中等,对性能要求较高的场景 |
线程池 | 避免频繁创建线程,性能稳定 | 实现稍复杂,需处理同步互斥 | 客户端连接数多(高并发)的场景 |
七、异常处理与实用技巧
TCP编程中,异常情况很常见(比如客户端突然断网、服务器端口被占用),需要针对性处理。同时还有一些实用技巧能提升服务的稳定性。
7.1 处理write失败:SIGPIPE信号
如果客户端已经断开连接,服务器还继续用write
往conn_fd
写数据,操作系统会给服务器发SIGPIPE
信号——默认情况下,这个信号会让服务器进程直接崩溃。
解决办法:在服务器启动时,忽略SIGPIPE
信号:
#include <signal.h>int main() {// 忽略SIGPIPE信号,避免write失败导致进程崩溃signal(SIGPIPE, SIG_IGN);// ... 后面的代码 ...
}
同时,要检查write
的返回值,如果返回-1,说明写入失败,需要关闭conn_fd
并退出循环:
ssize_t n = write(conn_fd, buffer, strlen(buffer));
if (n < 0) {perror("write error");close(conn_fd);break;
}
7.2 客户端断线重连
客户端可能因为网络波动断开连接,需要实现“自动重连”逻辑:如果读写失败,循环尝试重新连接服务器,直到成功或达到重连次数上限。
客户端断线重连代码(关键部分)
#include <unistd.h>// 重连函数:尝试count次,每次间隔interval秒
int reconnect(const char* server_ip, uint16_t server_port, int count, int interval) {int sock_fd;struct sockaddr_in server_addr;memset(&server_addr, 0, sizeof(server_addr));server_addr.sin_family = AF_INET;server_addr.sin_port = htons(server_port);inet_pton(AF_INET, server_ip, &server_addr.sin_addr);for (int i = 0; i < count; ++i) {sock_fd = socket(AF_INET, SOCK_STREAM, 0);if (sock_fd < 0) {perror("socket error");sleep(interval);continue;}int ret = connect(sock_fd, (struct sockaddr*)&server_addr, sizeof(server_addr));if (ret == 0) {printf("重连服务器[%s:%d]成功!\n", server_ip, server_port);return sock_fd; // 重连成功,返回新的sock_fd}perror("connect error(重连失败)");close(sock_fd);sleep(interval); // 间隔interval秒后重试}printf("重连%d次失败,客户端离线\n", count);return -1;
}// 通信逻辑中添加重连
int main() {// ... 解析服务器IP和端口 ...int sock_fd = -1;// 第一次连接sock_fd = reconnect(server_ip, server_port, 3, 2);if (sock_fd < 0) {exit(1);}char buffer[4096];while (1) {printf("请输入消息:");fflush(stdout);if (fgets(buffer, sizeof(buffer), stdin) == NULL) break;// 发送消息ssize_t n = write(sock_fd, buffer, strlen(buffer)-1); // 去掉换行符if (n < 0) {perror("write error(连接断开,尝试重连)");close(sock_fd);// 重连3次,每次间隔2秒sock_fd = reconnect(server_ip, server_port, 3, 2);if (sock_fd < 0) {break;}continue;}// 接收回显n = read(sock_fd, buffer, sizeof(buffer)-1);if (n <= 0) {perror("read error(连接断开,尝试重连)");close(sock_fd);sock_fd = reconnect(server_ip, server_port, 3, 2);if (sock_fd < 0) {break;}continue;}buffer[n] = '\0';printf("服务器回显:%s\n", buffer);}close(sock_fd);return 0;
}
7.3 地址端口复用:解决TIME_WAIT问题
服务器关闭后,端口会进入TIME_WAIT
状态(默认保持2分钟)——这是TCP的设计,确保最后一个ACK包被对方收到,避免数据丢失。但这会导致服务器无法立即重启(提示“Address already in use”)。
解决办法:用setsockopt
函数设置SO_REUSEADDR
和SO_REUSEPORT
选项,允许套接字复用本地地址和端口。
代码实现(在socket创建后、bind前调用)
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {perror("socket error");return -1;
}// 设置地址复用
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 设置端口复用(Linux 3.9+支持)
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));// 之后再调用bind...
八、守护进程:让服务后台常驻
我们之前写的服务器,一旦关闭终端(比如SSH连接断开),进程就会跟着退出——这是因为服务器是“前台进程”,依赖终端会话。要让服务器像真正的服务(比如Nginx、MySQL)一样后台常驻,需要把它变成守护进程。
8.1 守护进程的核心特性
- 脱离终端会话:不依赖任何终端,终端关闭后进程继续运行;
- 后台运行:不在前台显示输出,日志通常写入文件;
- 自成进程组和会话:不与其他进程共享进程组,避免被信号影响。
8.2 实现守护进程的步骤
- fork子进程,父进程退出:子进程会被
init
进程(PID=1)领养,脱离原终端; - 创建新会话(setsid):子进程成为新会话的组长,脱离原会话;
- 忽略无关信号:比如
SIGINT
(Ctrl+C)、SIGTSTP
(Ctrl+Z),避免被终端信号终止; - 更改工作目录:通常改为
/
(根目录),避免原工作目录被删除导致进程出错; - 重定向标准输入/输出/错误:把
0
(stdin)、1
(stdout)、2
(stderr)重定向到/dev/null
(黑洞文件),避免输出干扰。
8.3 守护进程代码实现
#include <sys/stat.h>
#include <fcntl.h>void daemonize() {// 1. fork子进程,父进程退出pid_t pid = fork();if (pid < 0) {perror("fork error");exit(1);} else if (pid > 0) {exit(0); // 父进程退出}// 2. 创建新会话,成为会话组长if (setsid() < 0) {perror("setsid error");exit(1);}// 3. 忽略无关信号signal(SIGINT, SIG_IGN); // 忽略Ctrl+Csignal(SIGTSTP, SIG_IGN); // 忽略Ctrl+Zsignal(SIGCHLD, SIG_IGN); // 忽略子进程退出信号// 4. 更改工作目录为根目录if (chdir("/") < 0) {perror("chdir error");exit(1);}// 5. 重定向标准输入/输出/错误到/dev/nullint fd = open("/dev/null", O_RDWR);if (fd < 0) {perror("open /dev/null error");exit(1);}// 重定向stdin(0)、stdout(1)、stderr(2)dup2(fd, 0);dup2(fd, 1);dup2(fd, 2);close(fd);
}int main() {// 先变成守护进程daemonize();// ... 后面的TCP服务器代码(创建套接字、绑定、监听、处理连接) ...return 0;
}
注意:日志处理
守护进程的输出被重定向到/dev/null
,所以需要把日志写入文件(比如/var/log/tcp_server.log
),方便后续排查问题。可以自己实现一个简单的日志函数:
#include <time.h>void write_log(const char* log) {FILE* fp = fopen("/var/log/tcp_server.log", "a");if (fp == NULL) return;// 获取当前时间time_t now = time(NULL);struct tm* tm = localtime(&now);char time_str[64];strftime(time_str, sizeof(time_str), "[%Y-%m-%d %H:%M:%S]", tm);// 写入日志fprintf(fp, "%s %s\n", time_str, log);fclose(fp);
}// 使用示例
write_log("TCP服务器启动,端口8888");
九、TCP协议核心机制:三次握手与四次挥手
前面我们讲的是TCP编程实现,现在深入TCP协议的底层——三次握手(建立连接)和四次挥手(释放连接),这是TCP可靠传输的基础。
9.1 三次握手:建立可靠连接
TCP是面向连接的协议,通信前必须通过三次握手建立连接。三次握手的核心目的是:
- 确认双方的发送和接收能力正常;
- 同步双方的“序列号”(TCP用序列号保证数据按序到达,避免丢失和重复)。
三次握手过程
-
第一次握手(客户端→服务器):
- 客户端发送
SYN
报文(同步报文),报文中包含客户端的初始序列号ISN_c
; - 客户端进入
SYN_SENT
状态,等待服务器响应。 - 类比:你给朋友打电话,拨号后说“喂,能听到吗?”(发起连接请求)。
- 客户端发送
-
第二次握手(服务器→客户端):
- 服务器收到
SYN
报文后,回复SYN+ACK
报文:SYN
:服务器的初始序列号ISN_s
;ACK
:确认客户端的ISN_c
,确认号为ISN_c + 1
;
- 服务器进入
SYN_RCVD
状态,等待客户端确认。 - 类比:朋友接到电话,说“能听到,你能听到我吗?”(确认收到请求,同时发起自己的请求)。
- 服务器收到
-
第三次握手(客户端→服务器):
- 客户端收到
SYN+ACK
报文后,回复ACK
报文,确认号为ISN_s + 1
; - 客户端进入
ESTABLISHED
状态(连接建立,可读写数据); - 服务器收到
ACK
后,也进入ESTABLISHED
状态。 - 类比:你说“能听到,我们开始聊吧!”(确认收到朋友的请求,连接建立)。
- 客户端收到
为什么需要三次握手?
如果是两次握手,服务器发送SYN+ACK
后就认为连接建立,但如果SYN+ACK
报文丢失,客户端没收到,会重新发送SYN
,而服务器会创建多个无效连接,浪费资源。三次握手能确保双方都确认“对方能收到自己的消息”,避免资源浪费。
9.2 四次挥手:释放连接
TCP连接是全双工的,双方都需要单独释放自己的发送通道,所以需要四次挥手。
四次挥手过程
-
第一次挥手(客户端→服务器):
- 客户端没有数据要发了,发送
FIN
报文(结束报文),表示“我这边要关闭发送通道了”; - 客户端进入
FIN_WAIT_1
状态,等待服务器确认。 - 类比:你说“我没什么要说的了,挂了啊?”(发起关闭请求)。
- 客户端没有数据要发了,发送
-
第二次挥手(服务器→客户端):
- 服务器收到
FIN
报文后,回复ACK
报文,确认号为客户端FIN
的序列号+1; - 服务器进入
CLOSE_WAIT
状态,此时服务器还能给客户端发数据; - 客户端收到
ACK
后,进入FIN_WAIT_2
状态,等待服务器的FIN
报文。 - 类比:朋友说“好的,我知道了,我再跟你说最后一句…”(确认收到关闭请求,还能继续发数据)。
- 服务器收到
-
第三次挥手(服务器→客户端):
- 服务器也没有数据要发了,发送
FIN
报文,表示“我这边也关闭发送通道了”; - 服务器进入
LAST_ACK
状态,等待客户端确认。 - 类比:朋友说“好了,我说完了,挂吧!”(发起自己的关闭请求)。
- 服务器也没有数据要发了,发送
-
第四次挥手(客户端→服务器):
- 客户端收到
FIN
报文后,回复ACK
报文,确认号为服务器FIN
的序列号+1; - 客户端进入
TIME_WAIT
状态(默认2分钟),等待可能的重传ACK
; - 服务器收到
ACK
后,进入CLOSED
状态(连接释放); - 客户端等待
TIME_WAIT
超时后,也进入CLOSED
状态。 - 类比:你说“好的,挂了!”(确认收到关闭请求),但你会等几秒再挂电话,确保朋友听到你的话。
- 客户端收到
为什么需要TIME_WAIT状态?
- 确保服务器能收到最后一个
ACK
报文(如果ACK
丢失,服务器会重传FIN
,客户端在TIME_WAIT
期间能重新发送ACK
); - 避免旧连接的报文干扰新连接(
TIME_WAIT
期间,旧连接的报文会过期)。
9.3 TCP全双工:为什么能同时收发数据?
TCP能实现全双工通信,核心是底层有两个独立的缓冲区:
- 发送缓冲区:应用层调用
write
时,数据会先拷贝到发送缓冲区,TCP协议栈会负责把缓冲区的数据发出去(处理重传、流量控制); - 接收缓冲区:TCP协议栈收到数据后,会把数据放到接收缓冲区,应用层调用
read
时,从接收缓冲区读取数据。
这两个缓冲区是独立的,所以通信双方可以同时发送和接收数据——就像两个人同时说话,各自的“发送通道”和“接收通道”互不干扰。
十、总结
为了方便复习,我们把TCP的核心知识点整理成一个框架:
1. TCP协议特性
- 面向连接、面向字节流、可靠传输、全双工;
- 基于三次握手建立连接,四次挥手释放连接;
- 有流量控制(滑动窗口)和拥塞控制(避免网络拥堵)机制。
2. TCP编程核心步骤
- 服务器:创建套接字(socket)→ 绑定地址(bind)→ 监听(listen)→ 获取连接(accept)→ 读写数据(read/write)→ 关闭套接字(close);
- 客户端:创建套接字(socket)→ 发起连接(connect)→ 读写数据(read/write)→ 关闭套接字(close)。
3. 关键概念
- 监听套接字 vs 通信套接字:分工不同,监听套接字负责拉客,通信套接字负责服务;
- 并发模型:多进程(稳定)、多线程(轻量)、线程池(高并发);
- 异常处理:忽略SIGPIPE、断线重连、地址端口复用;
- 守护进程:脱离终端,后台常驻。
4. 协议底层
- 三次握手:确认双方能力,同步序列号;
- 四次挥手:全双工释放,TIME_WAIT确保可靠;
- 全双工:基于发送/接收缓冲区,独立收发数据。