当前位置: 首页 > news >正文

TCP协议详解

TCP协议详解

在网络编程中,TCP(传输控制协议)是当之无愧的核心——它的面向连接可靠传输特性,支撑了绝大多数互联网应用(比如浏览网页、即时通讯、文件传输)。如果你未来需要复习TCP相关知识,这篇内容会从编程实现到协议底层,带你一步步吃透TCP的所有关键知识点。文章会穿插代码示例和通俗比喻,偶尔也会抛出问题让你主动思考,帮你加深记忆。

一、TCP协议的核心特性

在写代码之前,我们得先明确TCP的核心优势——这些优势决定了它的编程逻辑和使用场景:

  1. 面向连接:通信前必须先建立连接(类似打电话,先拨号通了再说话),通信后要释放连接;
  2. 面向字节流:数据以字节为单位连续传输,没有“报文边界”(类似读文件,一次读多少由你决定);
  3. 可靠传输:通过确认、重传、流量控制、拥塞控制等机制,保证数据不丢、不重、按序到达;
  4. 全双工通信:通信双方可以同时发送和接收数据(类似两个人同时说话,互不干扰)。

对比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 关键注意点

  1. 监听套接字 vs 通信套接字
    • 监听套接字(listen_fd):全程只负责监听,不会参与数据读写;
    • 通信套接字(conn_fd):每个客户端对应一个,负责与该客户端的读写,客户端断开后要关闭。
  2. 字节序转换
    • 客户端的IP(client_addr.sin_addr)和端口(client_addr.sin_port)是网络字节序,需要用inet_ntop(IP转字符串)和ntohs(端口转主机序)转换后才能正常显示;
    • 数据内容(比如客户端发的“hello”)不需要手动转换——TCP会自动处理字节序,我们只需要管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、退出状态),子进程会变成僵尸进程(占用系统资源,无法被调度)。解决办法有两种:

  1. 注册SIGCHLD信号处理函数:子进程退出时会给父进程发SIGCHLD信号,父进程在信号处理函数中用waitpid(-1, NULL, WNOHANG)非阻塞回收所有僵尸子进程;
  2. 二次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信号

如果客户端已经断开连接,服务器还继续用writeconn_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_REUSEADDRSO_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 实现守护进程的步骤

  1. fork子进程,父进程退出:子进程会被init进程(PID=1)领养,脱离原终端;
  2. 创建新会话(setsid):子进程成为新会话的组长,脱离原会话;
  3. 忽略无关信号:比如SIGINT(Ctrl+C)、SIGTSTP(Ctrl+Z),避免被终端信号终止;
  4. 更改工作目录:通常改为/(根目录),避免原工作目录被删除导致进程出错;
  5. 重定向标准输入/输出/错误:把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是面向连接的协议,通信前必须通过三次握手建立连接。三次握手的核心目的是:

  1. 确认双方的发送和接收能力正常;
  2. 同步双方的“序列号”(TCP用序列号保证数据按序到达,避免丢失和重复)。
三次握手过程
  1. 第一次握手(客户端→服务器)

    • 客户端发送SYN报文(同步报文),报文中包含客户端的初始序列号ISN_c
    • 客户端进入SYN_SENT状态,等待服务器响应。
    • 类比:你给朋友打电话,拨号后说“喂,能听到吗?”(发起连接请求)。
  2. 第二次握手(服务器→客户端)

    • 服务器收到SYN报文后,回复SYN+ACK报文:
      • SYN:服务器的初始序列号ISN_s
      • ACK:确认客户端的ISN_c,确认号为ISN_c + 1
    • 服务器进入SYN_RCVD状态,等待客户端确认。
    • 类比:朋友接到电话,说“能听到,你能听到我吗?”(确认收到请求,同时发起自己的请求)。
  3. 第三次握手(客户端→服务器)

    • 客户端收到SYN+ACK报文后,回复ACK报文,确认号为ISN_s + 1
    • 客户端进入ESTABLISHED状态(连接建立,可读写数据);
    • 服务器收到ACK后,也进入ESTABLISHED状态。
    • 类比:你说“能听到,我们开始聊吧!”(确认收到朋友的请求,连接建立)。
为什么需要三次握手?

如果是两次握手,服务器发送SYN+ACK后就认为连接建立,但如果SYN+ACK报文丢失,客户端没收到,会重新发送SYN,而服务器会创建多个无效连接,浪费资源。三次握手能确保双方都确认“对方能收到自己的消息”,避免资源浪费。

9.2 四次挥手:释放连接

TCP连接是全双工的,双方都需要单独释放自己的发送通道,所以需要四次挥手。

四次挥手过程
  1. 第一次挥手(客户端→服务器)

    • 客户端没有数据要发了,发送FIN报文(结束报文),表示“我这边要关闭发送通道了”;
    • 客户端进入FIN_WAIT_1状态,等待服务器确认。
    • 类比:你说“我没什么要说的了,挂了啊?”(发起关闭请求)。
  2. 第二次挥手(服务器→客户端)

    • 服务器收到FIN报文后,回复ACK报文,确认号为客户端FIN的序列号+1;
    • 服务器进入CLOSE_WAIT状态,此时服务器还能给客户端发数据;
    • 客户端收到ACK后,进入FIN_WAIT_2状态,等待服务器的FIN报文。
    • 类比:朋友说“好的,我知道了,我再跟你说最后一句…”(确认收到关闭请求,还能继续发数据)。
  3. 第三次挥手(服务器→客户端)

    • 服务器也没有数据要发了,发送FIN报文,表示“我这边也关闭发送通道了”;
    • 服务器进入LAST_ACK状态,等待客户端确认。
    • 类比:朋友说“好了,我说完了,挂吧!”(发起自己的关闭请求)。
  4. 第四次挥手(客户端→服务器)

    • 客户端收到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确保可靠;
  • 全双工:基于发送/接收缓冲区,独立收发数据。
http://www.dtcms.com/a/479456.html

相关文章:

  • 如何进行一个网站建设网站开发赚不赚钱
  • 【c++】:Lambda 表达式介绍和使用
  • 四川建设厅网站施工员证查询网站建设找盛誉网络
  • 了解网站开发 后台流程详情页尺寸
  • 2025年10月13日
  • 使用Reindex迁移Elasticsearch集群数据详解(上)
  • 网站设计 优帮云北京做网站公司电话
  • 上海高端网站制作公司专业网站设计建设服务
  • 大模型-CLIP 双编码器架构如何优化图文关联
  • [Qlib] `Model` | `fit` `predict`
  • 线程池Executors
  • 莆田企业网站建设网站建设的会计核算
  • Redis集群架构详解:如何实现高可用和高性能
  • 凤岗网站建设电商系统架构图
  • 知乎 上海做网站的公司自己做一个网站需要多少钱
  • 广州网站开发怎么做如何利用网站来提升企业形象
  • ESD防护设计宝典(八):能量的阀门——电源分配网络(PDN)设计
  • 怎么建设网站规划网站开场动画怎么做
  • 帝国cms怎么做网站地图竞价推广代运营公司
  • C语言--VSCode开发环境配置
  • 企业网站建设智恒网络山东网站营销seo哪家好
  • 12380网站建设打算手机网站建设liedns
  • 为什么做营销型网站网站的经营推广
  • 公章在线制作网站沈阳建设工程质量安全
  • vtkImageThreshold 图像阈值处理指南:从基础到实战优化
  • 佳木斯网站建设公司企业产品展示网站源码
  • MySQL8数据库高级特性
  • 遵义网站建设gzyhg设计一个网站多少钱
  • 设置自己的网站php+mysql网站开发...
  • C++ Builder XE在RzListView1中使用 Selected 属性获取行号,双击显示选中某行的行号