1.经典模型
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h> // 必须包含线程库
#include <arpa/inet.h>
#include <sys/socket.h>#define PORT 8080
#define BUFFER_SIZE 1024// 线程处理函数:专门负责与某个特定客户端通信
void *handle_client(void *socket_desc) {int sock = *(int*)socket_desc;free(socket_desc); // 释放主线程申请的内存char buffer[BUFFER_SIZE];int read_size;// 循环读取该客户端发送的消息while ((read_size = recv(sock, buffer, BUFFER_SIZE, 0)) > 0) {buffer[read_size] = '\0';printf("收到客户端(Socket %d): %s\n", sock, buffer);// 回复客户端char *message = "消息已收到";send(sock, message, strlen(message), 0);}if (read_size == 0) {printf("客户端(Socket %d) 已断开连接\n", sock);} else if (read_size == -1) {perror("recv failed");}close(sock);pthread_exit(NULL);
}int main() {int server_fd, new_socket;struct sockaddr_in address;int addrlen = sizeof(address);// 创建套接字server_fd = socket(AF_INET, SOCK_STREAM, 0);// 设置地址重用(避免重启服务器时端口被占用的错误)int opt = 1;setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));address.sin_family = AF_INET;address.sin_addr.s_addr = INADDR_ANY;address.sin_port = htons(PORT);bind(server_fd, (struct sockaddr *)&address, sizeof(address));listen(server_fd, 10); // 监听队列大小设为 10printf("并发服务端已启动,等待连接...\n");while (1) {// 主线程阻塞在这里等待新连接new_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen);if (new_socket < 0) {perror("accept failed");continue;}printf("新客户端已连接!分配 Socket: %d\n", new_socket);// 为每个新连接动态分配内存存储套接字,防止竞争int *new_sock_ptr = malloc(sizeof(int));*new_sock_ptr = new_socket;pthread_t sniffer_thread;// 创建新线程if (pthread_create(&sniffer_thread, NULL, handle_client, (void*)new_sock_ptr) < 0) {perror("could not create thread");free(new_sock_ptr);}// 设置线程为分离模式,线程结束后自动回收资源pthread_detach(sniffer_thread);}close(server_fd);return 0;
}
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>#define PORT 8080
#define BUFFER_SIZE 1024int main() {int sock = 0;struct sockaddr_in serv_addr;char buffer[BUFFER_SIZE] = {0};char message[BUFFER_SIZE];// 1. 创建套接字if ((sock = socket(AF_INET, SOCK_STREAM, 0)) < 0) {printf("\n Socket creation error \n");return -1;}serv_addr.sin_family = AF_INET;serv_addr.sin_port = htons(PORT);// 2. 转换 IP 地址if (inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr) <= 0) {printf("\nInvalid address/ Address not supported \n");return -1;}// 3. 连接服务端if (connect(sock, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {printf("\nConnection Failed \n");return -1;}printf("连接服务器成功!输入消息并回车发送(输入 'exit' 退出):\n");// 4. 循环通信while (1) {printf("> ");if (fgets(message, BUFFER_SIZE, stdin) == NULL) break;// 去掉末尾的换行符message[strcspn(message, "\n")] = 0;// 如果输入 exit 则退出if (strcmp(message, "exit") == 0) {break;}// 发送数据if (send(sock, message, strlen(message), 0) < 0) {perror("Send failed");break;}// 接收服务端响应int valread = recv(sock, buffer, BUFFER_SIZE, 0);if (valread > 0) {buffer[valread] = '\0';printf("服务端回复: %s\n", buffer);} else if (valread == 0) {printf("服务端断开连接\n");break;} else {perror("Recv failed");break;}}// 5. 关闭套接字close(sock);printf("已断开连接。\n");return 0;
}
TCP 服务器初始化流程解析
1. 创建套接字 (socket)
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
- 作用:向操作系统申请一个网络通信端点(文件描述符)。
- 参数解释:
AF_INET:使用 IPv4 协议族。SOCK_STREAM:指定使用 面向连接的流式套接字(即 TCP)。0:由系统根据前两个参数自动选择协议(通常就是 IPPROTO_TCP)。
2. 准备地址结构体 (sockaddr_in)
在绑定之前,需要定义服务器在哪个 IP 和端口上“摆摊”。
struct sockaddr_in serveraddr;
memset(&serveraddr, 0, sizeof(struct sockaddr_in)); // 清零,防止随机值干扰serveraddr.sin_family = AF_INET; // IPv4
serveraddr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听本机所有网卡地址
serveraddr.sin_port = htons(2048); // 监听 2048 端口
INADDR_ANY:这是一个宏,表示服务器将监听电脑上所有的网络接口(比如 Wi-Fi 和以太网)。- 字节序转换:
htonl(Host to Network Long):将长整型从主机字节序转换为网络字节序(大端序)。htons(Host to Network Short):将短整型(端口号)转换为网络字节序。这是必须的,因为不同计算机存储数字的方式可能不同,但网络传输必须统一。
3. 绑定端口 (bind)
if (-1 == bind(sockfd, (struct sockaddr *)&serveraddr, sizeof(struct sockaddr))) {perror("bind");return -1;
}
- 作用:将上面创建的
sockfd(逻辑上的插座)与serveraddr(物理上的 IP 和端口)联系起来。 - 类型转换:由于
bind是通用的系统调用,它接受struct sockaddr*。但我们使用的是 IPv4 特有的struct sockaddr_in,所以需要进行强制类型转换。
4. 监听连接 (listen)
listen(sockfd, 10);
- 作用:告诉操作系统,这个套接字现在进入“被动监听”状态,准备接收客户端的连接请求。
- 参数
10:这是 全连接队列 (Backlog) 的大小。它表示在服务器调用accept处理之前,系统内核允许缓存的待处理连接的最大数量。
核心概念总结
| 函数 | 目的 | 类比 |
|---|---|---|
socket() |
创建端点 | 安装一部电话机 |
bind() |
关联地址与端口 | 给这部电话申请一个具体的号码(如 2048) |
listen() |
开启监听模式 | 坐在电话旁,等待电话铃声响起 |
htons/htonl |
字节序转换 | 确保拨号方的编码格式和接听方一致 |
2.标准流与文件描述符对应关系
在 Unix-like 系统(如 Linux)中,每一个进程在启动时,内核都会默认为其打开三个文件描述符。这三个文件描述符与 C 语言标准库提供的“标准流”有一一对应的关系。
1. 核心对应关系表
下表总结了它们在不同层级的名称与编号:
| 标准流名称 | 文件描述符 (FD) | POSIX 宏定义 (Integer) | C 语言标准宏 (FILE *) | 默认设备 |
|---|---|---|---|---|
| 标准输入 (Standard Input) | 0 | STDIN_FILENO |
stdin |
键盘 |
| 标准输出 (Standard Output) | 1 | STDOUT_FILENO |
stdout |
屏幕(终端) |
| 标准错误 (Standard Error) | 2 | STDERR_FILENO |
stderr |
屏幕(终端) |
2. 深度理解:两个层级的区别
理解这种对应关系的关键在于区分 C 标准库层 和 内核层:
A. 内核层:文件描述符 (File Descriptor, FD)
- 本质: 是一个非负整数。它实际上是内核中“进程文件描述符表”的索引。
- 特点: 它是系统调用(如
read(),write(),close())的操作句柄。它是低级的、无缓冲的。 - 头文件:
<unistd.h>
B. C 标准库层:标准流 (Standard Streams)
- 本质: 是一个指向
FILE结构体的指针(FILE *)。这个结构体内部封装了对应的文件描述符。 - 特点: 它是高级的、有缓冲的(例如
stdout通常是行缓冲)。使用printf(),fprintf(),scanf()等函数进行操作。 - 头文件:
<stdio.h>
3. 它们是如何关联的?
在程序启动(即我们之前讨论的 _start 阶段)时,操作系统会将这三个 FD 分配给终端设备。C 运行时库随后会创建三个 FILE 对象,并将它们的内部 FD 成员分别设置为 0, 1, 2。
你可以使用以下两个函数在两者之间转换:
fileno(FILE \*stream): 获取一个标准流背后的文件描述符。- 例如:
fileno(stdout)结果为1。
- 例如:
fdopen(int fd, const char \*mode): 将一个原始的文件描述符包装成 C 标准流。
4. 实际应用:重定向的本质
当你在 shell 中执行重定向时,本质上是在改变这些 FD 指向的对象:
./app > out.txt- Shell 在启动进程前,关闭 FD 1,然后打开
out.txt并将其分配给 FD 1。stdout依然往 FD 1 写数据,但数据流向了文件而非屏幕。
- Shell 在启动进程前,关闭 FD 1,然后打开
./app 2>&1- 这表示将标准错误(FD 2)重定向到标准输出(FD 1)目前所指向的地方。
5. 为什么 stderr 是 2 且通常不带缓冲?
stdout (1) 通常是行缓冲的,这意味着它会攒够一行才输出。而 stderr (2) 默认是无缓冲的。
这是为了确保当程序发生崩溃(Crash)时,错误信息能立即打印出来,而不是还留在缓冲区里没来得及写出。
3.文件描述符回收
在 Linux 系统中,文件描述符 (File Descriptor, FD) 的回收主要发生在以下几种情况。理解这一点对于编写稳定的网络程序(如你之前的 TCP 服务器)至关重要,因为 FD 是有限的资源。
1. 主动回收:调用 close()
这是最常用、也最推荐的方式。当你完成对套接字或文件的操作后,显式调用 close(fd)。
- 引用计数:内核会维护每个文件描述符的引用计数。调用
close会使计数减 1。 - 真正释放:当引用计数减到 0 时,内核会释放该文件资源,并回收该数字编号(如
3),以便下次socket()或open()调用时重复使用。
2. 进程退出:自动回收
当一个进程正常结束(return 0)或异常崩溃时,操作系统会接管并清理该进程占用的资源。
- 内核清理:操作系统会自动关闭该进程打开的所有文件描述符。
- 风险提示:虽然系统会“兜底”,但在编写像服务器这样需要长期运行的程序时,绝不能依赖这种方式。如果不主动关闭,会导致“文件描述符泄漏”,最终系统会报错
Too many open files,导致新连接无法建立。
3. 执行 exec 系列函数:FD_CLOEXEC
如果你在程序中使用 fork() 创建了子进程,子进程默认会继承父进程的所有文件描述符。
- 关闭标志:如果在打开文件或套接字时设置了
FD_CLOEXEC标志,那么当子进程执行exec(加载新程序)时,这些文件描述符会被自动关闭。
4. 异常情况:连接断开与回收
这里有一个常见的误区:客户端断开连接,服务器的 sockfd 会自动回收吗?
- 答案是:不会。
- 即使 TCP 连接因为网络原因或对方关闭而断开,服务器端的
sockfd依然占用着数字编号。 - 正确做法:服务器必须通过
read()或recv()返回 0 来判断对方已关闭,然后手动调用close(sockfd)才能回收。
总结:FD 的生命周期
| 场景 | 回收机制 | 优先级 |
|---|---|---|
| 正常通信结束 | 开发者调用 close(fd) |
最高(最规范) |
| 程序运行中崩溃 | 操作系统强制收回所有 FD | 被动触发 |
| 服务器进程重启 | 进程退出时系统自动释放 | 系统兜底 |
4.shutdown 和 close
在网络编程中,close 和 shutdown 都可以用来关闭套接字,但它们的工作机制和影响范围有显著区别。
简单来说:close 是释放资源(回收 ID),而 shutdown 是切断连接状态(发送信号)。
1. close(fd):彻底终结与资源回收
close 是最通用的文件操作函数。它的核心逻辑是减少引用计数。
- 引用计数机制:在 Linux 中,多个进程可以共享同一个文件描述符(例如通过
fork())。每次调用close,内核会将该 FD 的引用计数减 1。 - 真正关闭:只有当引用计数减为 0 时,
close才会真正触发 TCP 的四次挥手(发送 FIN 包),并切断读写双向通道。 - 回收描述符:一旦调用成功,该文件描述符数字(如
3)就会被释放,系统可以把它分配给新的socket或open调用。 - 局限性:你无法选择只关闭发送或只关闭接收,它是“全关”。
2. shutdown(sockfd, how):精细的连接控制
shutdown 是专为套接字设计的函数。它不关心引用计数,而是直接操作 TCP 的状态机。
- 忽略引用计数:即使有 10 个进程共享同一个
sockfd,其中一个进程调用shutdown,连接也会立即被切断,影响所有进程。 - 半关闭(Half-close)能力:这是它最强大的地方,你可以通过
how参数精确控制:SHUT_RD(0):关闭读端。丢弃接收缓冲区的数据,不再接收新数据。SHUT_WR(1):关闭写端(最常用)。向对方发送FIN包,告诉对方“我发完了”,但你依然可以读取对方发来的数据。SHUT_RDWR(2):同时关闭读写。效果相当于前两者的结合。
- 不回收描述符:调用
shutdown后,文件描述符依然被占用,你必须后续再调用一次close才能回收 FD 数字。
3. 核心差异对比表
| 特性 | close() | shutdown() |
|---|---|---|
| 功能本质 | 关闭文件句柄,释放系统资源 | 改变 TCP 连接状态,不释放资源 |
| 引用计数影响 | 引用计数减到 0 才会真正发 FIN | 强制发送 FIN,不看引用计数 |
| 控制粒度 | 只能全双工关闭(读写都关) | 支持“半关闭”(只关读或只关写) |
| 后续 FD 可用性 | FD 立即失效,不能再传给系统调用 | FD 依然有效,最后仍需 close 回收 |
| 使用场景 | 普通的连接结束 | 优雅退出、确保数据完整传输 |
4. 为什么要用 shutdown?(典型场景:优雅关闭)
想象一个客户端向服务器上传大型文件的情况:
- 发送完毕:客户端发送完最后一个字节。
- 告知对方:客户端调用
shutdown(sockfd, SHUT_WR)。- 此时,客户端发送了
FIN包给服务器。 - 关键点:客户端的读通道依然开启。
- 此时,客户端发送了
- 接收确认:服务器处理完文件,发回一个“上传成功”的确认消息。
- 最终关闭:客户端读取到该消息后,调用
close(sockfd)彻底释放资源。
如果不这么做:如果你直接调用
close,读写通道同时关闭。如果服务器在处理完文件后还有反馈信息,客户端将无法读取,甚至会导致服务器因写入已关闭的连接而产生SIGPIPE错误。
总结建议
- 如果你只是想退出程序:直接用
close。 - 如果你在写健壮的服务器/客户端协议:使用
shutdown(SHUT_WR)来实现“优雅关闭”,确保数据不丢失。
5.recv和send
一、send 函数解析
原型:
int send(SOCKET s, const char *buf, int len, int flags);
- s:要发送的套接字描述符
- buf:发送数据缓冲区指针
- len:实际要发送的数据字节数
- flags:一般设置为 0,特殊情况可用于控制发送行为
执行流程与原理:
- 程序调用 send 时,其实并不是直接将数据发送到对端,而是先将 buf 的数据拷贝到该 socket 的发送缓冲区(内存空间),然后由操作系统内核和协议栈负责通过网卡将数据发往目标主机。
- 若当前发送缓冲区剩余空间足够,数据被快速拷贝到缓冲区,send 立即返回(但数据未必已被实际发出去)。
- 如果缓冲区空间不够,send 会等待协议栈传送部分数据腾出空间,有时会阻塞,直到空间满足需求。
- 发生错误(如网络断开)或者拷贝失败,send 返回 SOCKET_ERROR。
- 在非阻塞模式下,如果缓冲区空间为 0,直接返回 -1 并设置 errno 为 EAGAIN。
注意:
- send 返回值为实际拷贝进缓冲区的字节数;
- 数据被拷贝进缓冲区后,并非一定立即到达对方,后续若网络出错,下一个 socket 函数调用会报错。
二、recv 函数解析
原型:
int recv(SOCKET s, char *buf, int len, int flags);
- s:接收端套接字描述符
- buf:存放接收到数据的缓冲区指针
- len:最多接收的字节数
- flags:一般设置为 0
执行流程与原理:
- 内核网络中接收到的数据先存储到套接字的接收缓冲区,应用进程调用 recv 时,就是从接收缓冲区将数据拷贝到 buf 中。
- recv操作可能一次只拿到部分数据,应用需自行循环多次调用以完成全部数据的接收,一般会以返回值控制是否已读完。
- 当缓冲区没有数据或者协议栈还在传输,recv会阻塞并等待直到有数据到达(或网络断开)。
- 返回值为实际拷贝到用户缓冲区的字节数,返回 0 表示对端关闭连接或网络中断,返回 SOCKET_ERROR 表示调用或网络发生错误。
三、缓冲区机制
- 发送缓冲区(send buffer):数据由 send 拷贝至此,内核后续负责从这里真正写到网卡。
- 接收缓冲区(recv buffer):网卡收到的数据由内核放入这里,应用调用 recv 时才真正读走。
- 如果接收缓冲区满,则会触发 TCP 流量控制(滑动窗口 win=0,通知对方停止发送数据,保障可靠数据传输);
- 用户可通过 setsockopt 调整缓冲区大小以优化性能。
四、sizeof(buffer)和count
1. 核心逻辑:count 是“真实有效数据量”的唯一凭证
在执行 int count = recv(clientfd, buffer, sizeof(buffer), 0); 后:
sizeof(buffer):是你的容器(buffer)的总容量,比如 1024 字节。count:是当前这次recv调用真正从内核缓冲区拿出来的字节数。
为什么不能用 sizeof(buffer)?
如果你发送时写成 send(..., sizeof(buffer), ...),而 count 只有 10 字节,那么你会把 buffer 中剩余的 1014 字节“垃圾数据”也发给对方。这些数据可能是上次残留的旧数据,也可能是内存中的随机乱码。
为什么不能用固定长度(如 128)?
如果对方只发了 50 字节,你强制发送 128 字节,会导致数据冗余和协议解析错误;如果对方发了 200 字节,你只发 128 字节,会导致数据丢失。
2. 内存视图:有效载荷 vs. 缓冲区
想象一下你的 buffer 就像一个容量为 10 升的水桶:
- 第一步 (
recv):对方往你桶里倒了 3 升水。recv返回count = 3。 - 第二步 (
send):你想把这 3 升水倒给另一个人。- 如果你按照桶的大小(10升)去倒,你会把 3 升水加上 7 升空气(或杂质)一起倒过去。
- 如果你按照
count(3升)去倒,你倒出的恰好就是对方发给你的那部分。
3. TCP 的“流式”特性
TCP 是面向字节流的协议,它不保证发送方调用一次 send,接收方就一定通过一次 recv 接收到完全相同长度的数据。
- 发送方发了 100 字节。
- 接收方的内核可能先收到了 40 字节,
recv此时返回 40。 - 如果你此时不写
send(..., 40, ...)而是写send(..., 100, ...),程序会陷入逻辑混乱,因为它试图读取还没到达的数据。
4. 这种写法的标准模板(回显服务器)
在实际开发中,通常会这样组合使用,以确保安全:
char buffer[1024];
int count;// 1. 接收数据,拿到实际长度 count
count = recv(clientfd, buffer, sizeof(buffer), 0);if (count > 0) {// 2. 原样发回,只发送 count 字节int sent = send(sockfd, buffer, count, 0);if (sent < count) {// 处理“部分发送”的情况(高级进阶逻辑)}
} else if (count == 0) {// 对端关闭
} else {// 错误处理
}
四、常见注意事项和问题
- send 和 recv 都只负责在用户空间与内核空间间数据的拷贝,实际的数据网络传输由协议栈完成;
- 一次 recv 未必能收到全部数据,尤其是处理大量、大包时要循环读取;
- tcp “粘包/拆包”问题,本质是因为 TCP 是流式协议(无消息边界),需要应用层自己拆分(如协议长度字段);
- 返回值为 0:表示远程关闭连接,应主动 close;
- 多线程/异步场景下,要正确管理缓冲区以及数据完整性;
6.tcp的11种状态模型

TCP 的 11 种状态描述了从连接建立、数据传输到连接断开的整个生命周期。理解这些状态对于调试网络延迟、端口占用(如你之前提到的 TIME_WAIT)至关重要。
1. 建立连接阶段(三次握手)
当客户端与服务器尝试建立连接时,会涉及以下 4 种状态:
| 状态名称 | 描述 |
|---|---|
| CLOSED | 起始点。套接字处于关闭状态,没有任何连接。 |
| LISTEN | 服务器端状态。表示服务器正在监听特定端口,等待客户端的连接请求。 |
| SYN_SENT | 客户端状态。客户端发送了一个 SYN 包(请求建立连接),正在等待服务器确认。 |
| SYN_RCVD | 服务器端状态。服务器收到 SYN 包,并回送了 SYN+ACK,正在等待客户端的最后一个 ACK。 |
2. 数据传输阶段
| 状态名称 | 描述 |
|---|---|
| ESTABLISHED | 连接成功。双方握手完成,可以开始收发数据。这是最常见且应持续最久的状态。 |
3. 关闭连接阶段(四次挥手)
断开连接时,根据你是主动关闭还是被动关闭,会进入不同的状态:
主动关闭方(Active Closer)通常经历:
-
FIN_WAIT_1:应用层调用了
close(),发送了 FIN 包,等待对方的 ACK。 -
FIN_WAIT_2:收到了对方对 FIN 的 ACK,连接处于“半关闭”状态(你不能再发数据,但还能收数据)。正在等待对方发送 FIN。
-
CLOSING:特殊状态。双方同时发送 FIN 的罕见情况。
-
TIME_WAIT:最核心状态。收到了对方的 FIN 并发送了最后一个 ACK。此时连接必须等待 2MSL(报文最大生存时间)才能彻底回到 CLOSED。
作用:确保最后一个 ACK 对方收到了;防止旧连接的包干扰新连接。
被动关闭方(Passive Closer)通常经历:
- CLOSE_WAIT:收到了对方的 FIN 报文并回了 ACK。此时应用层需要检测到连接关闭并调用
close()。- 注:如果程序里出现大量
CLOSE_WAIT,通常是因为代码忘记调用close()函数。
- 注:如果程序里出现大量
- LAST_ACK:被动方也发出了 FIN 包,正在等待主动方的最后一个 ACK。
4. 状态转换逻辑图解
我们将 11 种状态串联起来,可以清晰看到它们是如何流转的:
- LISTEN $\rightarrow$ (收到 SYN) $\rightarrow$ SYN_RCVD $\rightarrow$ (收到 ACK) $\rightarrow$ ESTABLISHED
- CLOSED $\rightarrow$ (发送 SYN) $\rightarrow$ SYN_SENT $\rightarrow$ (收到 SYN+ACK) $\rightarrow$ ESTABLISHED
- ESTABLISHED $\rightarrow$ (发送 FIN) $\rightarrow$ FIN_WAIT_1 $\rightarrow$ FIN_WAIT_2 $\rightarrow$ TIME_WAIT $\rightarrow$ CLOSED
- ESTABLISHED $\rightarrow$ (收到 FIN) $\rightarrow$ CLOSE_WAIT $\rightarrow$ (发送 FIN) $\rightarrow$ LAST_ACK $\rightarrow$ CLOSED
总结:面试常考点
- TIME_WAIT 出现在哪一方?
- 永远出现在主动关闭连接的一方。
- 为什么需要 TIME_WAIT 状态?
- 防止“失效的报文”在网络中乱窜,影响后续相同五元组的新连接。
- 保证可靠地实现 TCP 全双工连接的终止(万一最后一个 ACK 丢了,对方会重发 FIN)。
- 大量 CLOSE_WAIT 的原因?
- 通常是业务逻辑 Bug:程序收到了对方关闭的消息(
recv返回 0),但没有及时调用close()释放资源。
- 通常是业务逻辑 Bug:程序收到了对方关闭的消息(
7.main是如何执行的
1. 操作系统加载阶段 (Loading)
当你启动一个程序(例如在终端输入 ./program)时,操作系统(OS)内核会接管控制权:
- 创建进程: OS 分配一个新的进程空间。
- 读取可执行文件: OS 读取文件头(如 Linux 的 ELF 或 Windows 的 PE),确定程序的代码段、数据段在哪。
- 分配内存: 将代码和静态数据加载到内存中,并为程序分配栈 (Stack) 和 堆 (Heap)。
- 动态链接: 如果程序使用了动态库(如
stdio.h对应的库),加载器会将这些库映射到进程的内存空间。
2. C 运行时启动阶段 (C Runtime Startup)
这是最关键的误区:程序并不是直接跳转到 main 的。
OS 实际上会将控制权交给程序中的“入口地址”,这通常是一个由编译器自动插入的函数,名为 _start(在 Linux C 中)。
_start 函数(由 C 运行时库 CRT 提供)会执行以下操作:
- 初始化环境变量: 设置
envp。 - 准备命令行参数: 将命令行输入的参数处理成
argc和argv。 - 初始化全局变量: 对全局变量和静态变量进行零初始化或赋予初值。
- 调用构造函数: 在 C++ 中,执行全局对象的构造函数。
- 调用
main: 此时,它才会正式调用int main(int argc, char *argv[])。
3. main 函数执行阶段
现在,控制权终于交到了你编写的代码手中:
- 压栈:
main的参数和返回地址被压入调用栈。 - 逻辑运行: 按照你编写的语句顺序执行。
- 系统调用: 如果你的
main中有printf或文件读写,会通过系统调用重新进入内核模式。
4. 退出清理阶段 (Termination)
当 main 函数执行完 return 0; 或执行到末尾时:
- 返回到
_start:main的返回值被传递回启动函数。 - 清理工作:
- 调用
atexit()注册的函数。 - 在 C++ 中,执行全局对象的析构函数。
- 冲刷 (Flush) 缓冲区,关闭打开的文件描述符。
- 调用
- 系统调用
_exit: 运行时库通过系统调用通知内核程序已结束。内核随后回收进程占用的所有内存和资源。
总结流程图
| 步骤 | 执行者 | 主要任务 |
|---|---|---|
| 1. 加载 | 操作系统内核 | 分配内存,加载二进制代码 |
| 2. 初始化 | C 运行时库 (_start) |
设置栈、环境、argc/argv、全局变量 |
| 3. 执行 | 你的代码 (main) |
运行业务逻辑 |
| 4. 清理 | C 运行时库 | 执行析构、清理缓冲区 |
| 5. 销毁 | 操作系统内核 | 回收资源,彻底结束进程 |
8.大量的timewait
1. 为什么会产生大量的 TIME_WAIT?
正如你上传的图片中提到的,频繁的短链接是根本原因。
核心触发机制:
- 主动关闭方:只有主动调用
close()关闭连接的一方,才会进入TIME_WAIT状态。 - 2MSL 等待周期:根据 TCP 协议,该状态会持续 2 MSL(Maximum Segment Lifetime,报文最大生存时间),在 Linux 上通常默认是 60 秒。
- 堆积效应:如果你的服务器每秒处理 1000 个短连接(如 HTTP 接口请求,处理完即断开),而每个连接都要停留 60 秒才消失,那么服务器上会瞬间堆积 $1000 \times 60 = 60,000$ 个
TIME_WAIT状态的连接。
2. 大量 TIME_WAIT 的危害
虽然 TIME_WAIT 状态不消耗 CPU,占用的内存也很小,但它的致命伤是占用端口/五元组:
- 端口耗尽:客户端连接服务器时,需要占用一个临时端口。Linux 默认的临时端口范围有限(通常约 3 万个)。如果
TIME_WAIT占满了这些端口,新的连接请求就会报错:Can't assign requested address。 - 连接效率下降:当内核需要在数万个
TIME_WAIT结构中查找可用端口时,会产生一定的资源消耗。
3. 典型的业务场景
- HTTP 短连接:在 HTTP/1.0 或未开启
Keep-Alive的 HTTP/1.1 中,服务器处理完请求后主动关闭连接,导致服务端出现大量TIME_WAIT。 - 爬虫系统:客户端频繁请求不同的目标服务器,导致客户端出现大量
TIME_WAIT。 - 反向代理(如 Nginx):Nginx 作为中转,既是服务端也是客户端。如果它与后端服务器(Upstream)之间使用短连接,Nginx 服务器会堆积海量的
TIME_WAIT。
4. 解决方案(面试常考点)
针对大量 TIME_WAIT,通常有以下三个层面的优化:
A. 应用层:改“短”为“长”
- 开启 Keep-Alive:通过长连接复用 TCP 链路,减少
close()的调用次数。这是最根本的解决办法。 - 使用连接池:比如数据库连接池、Redis 连接池,避免频繁的三次握手和四次挥手。
B. 内核参数优化(Linux sysctl)
如果业务场景必须使用短连接,可以调整 Linux 内核参数:
-
net.ipv4.tcp_tw_reuse = 1(最常用):
允许将处于 TIME_WAIT 状态的端口重新分配给新的连接(仅限于安全的情况)。
-
net.ipv4.tcp_max_tw_buckets = 5000:
设置系统范围内 TIME_WAIT 的最大数量,超过此值后,系统会直接销毁该状态并打印警告。
-
降低 net.ipv4.tcp_fin_timeout:
虽然不能直接改变 2MSL,但可以加快 FIN 状态的回收速度。
C. 网络层:负载均衡
- 使用更强大的负载均衡器,分发流量到更多的后端实例,稀释单个 IP 上的端口压力。
5. 补充知识:TIME_WAIT 与 CLOSE_WAIT 的区别
这是面试中极易混淆的一对:
TIME_WAIT:我主动关的,我没毛病,只是在等协议规定的时间结束。CLOSE_WAIT:对方关了,我还没关。如果代码里recv返回 0 后没有调用close(fd),就会一直卡在这个状态。出现大量CLOSE_WAIT通常意味着程序代码有 Bug(漏写了 close)。
9.SIGIO(基本没用)
SIGIO 是 Unix-like 系统中的一个信号(通常编号为 29 或 23),它属于信号驱动式 I/O (Signal-Driven I/O) 机制。
简单来说,它的作用是:当一个文件描述符(如 Socket、管道、终端)准备好进行读写时,内核主动发送这个信号通知进程。
1. SIGIO 的工作原理
在标准的阻塞式 I/O 中,进程会停下来等数据;在非阻塞轮询中,进程会不停地问“好了吗?”。而 SIGIO 实现了“别打扰我,好了你叫我”的机制。
执行流程通常如下:
- 建立信号处理函数: 进程为
SIGIO注册一个处理函数(Signal Handler)。 - 设置属主: 进程告诉内核:这个文件描述符(FD)归我管,有动静发信号给我。通过
fcntl(fd, F_SETOWN, getpid())实现。 - 开启异步通知: 进程通过
fcntl设置O_ASYNC标志,告诉内核开启信号驱动模式。 - 进程继续工作: 进程可以去执行其他任务,不需要阻塞等待。
- 信号触发: 当缓冲区有新数据到达(可读)或变为可写时,内核发送
SIGIO。 - 捕获并处理: 进程跳转到信号处理函数中,进行
read或write操作。
2. 代码实现步骤 (C 语言示例)
要让一个 FD 产生 SIGIO,需要以下核心代码:
// 1. 注册信号处理函数
signal(SIGIO, my_signal_handler);// 2. 将本进程设为文件描述符的“拥有者”
fcntl(fd, F_SETOWN, getpid());// 3. 获取旧标志并添加 O_ASYNC (异步) 和 O_NONBLOCK (非阻塞)
int flags = fcntl(fd, F_GETFL);
fcntl(fd, F_SETFL, flags | O_ASYNC | O_NONBLOCK);
3. SIGIO 的局限性
虽然 SIGIO 看起来很高效,但在现代高并发服务器开发中,它并不常用(通常被 epoll 取代),原因如下:
- 信号拥塞: 如果数据到达非常快,内核产生的信号可能覆盖或丢失。
- 无法区分 FD: 如果一个进程同时监听 100 个 Socket,当
SIGIO到达时,信号本身并不告诉你是哪个 FD 触发了。进程必须遍历所有的 FD 去检查谁动了。 - 线程安全问题: 信号处理函数会打断正常的程序执行流,处理不当时容易引起重入(Reentrancy)风险。
- Linux 的改进: Linux 后来引入了实时信号(Real-time Signals)来解决无法区分 FD 的问题,但依然不如
epoll这种专门的 I/O 多路复用机制成熟和强大。
4. 总结:它与 main 的关系
回到你之前问的程序执行过程: 如果你的程序在 main 中配置了 SIGIO,那么当 main 正在执行复杂的数学计算时,一旦网络数据包到达,CPU 会强制暂停 main 的当前指令,跳转到 SIGIO 的处理函数,执行完后再跳回 main 继续刚才的计算。
10.大量的closewait
出现大量的 CLOSE_WAIT 状态是一个非常典型的网络编程 Bug,通常意味着你的应用程序在 TCP 四次挥手过程中“掉链子”了。
与 TIME_WAIT(通常是内核或配置问题)不同,CLOSE_WAIT 几乎百分之百是应用程序(也就是你的代码)的问题。
1. TCP 四次挥手与 CLOSE_WAIT 的位置
要理解为什么会有 CLOSE_WAIT,必须看它在挥手过程中处于哪个环节:
- 对方: 发送
FIN(表示“我没数据要发了,我想关掉连接”)。 - 你的内核: 收到
FIN,自动回复一个ACK。 - 你的内核: 将连接状态设置为
CLOSE_WAIT,并通知你的应用程序(通过返回read为 0 或触发信号)。 - 关键点: 此时内核在等待你的应用程序调用
close()函数。 - 你的应用: 如果一直不调用
close(),这个连接就会永远停留在CLOSE_WAIT状态。
2. 为什么会出现大量 CLOSE_WAIT?
其核心原因只有一个:被动关闭方(你)没有关闭连接。 但具体场景通常有以下几种:
A. 代码逻辑漏洞
最常见的情况。在处理 Socket 的流程中,某个分支漏掉了 close()。
- 例子: 在循环读取数据时,虽然判断了
read() == 0(表示对方关闭),但在执行break跳出循环前,忘记调用close(fd)。
B. 线程/进程池被耗尽
- 如果你使用了“每个连接一个线程”的模型,而业务逻辑在处理时发生了死锁或长时间阻塞(例如等待数据库响应),导致该线程永远无法执行到最后的
close(),连接就会卡死在CLOSE_WAIT。
C. 连接池维护不当
- 在使用 HTTP 连接池或数据库连接池时,如果程序认为连接还在使用中,而实际上服务端(对方)已经因为超时关闭了连接,连接池没有正确检测到并释放 FD。
D. 信号处理冲突(联系你之前问的 SIGIO)
- 如果你使用
SIGIO或其他异步机制,可能在信号处理函数中发生了重入或逻辑死锁,导致正常的关闭流程被中断。
3. CLOSE_WAIT 的危害
不要轻视这个状态,它的危害通常比 TIME_WAIT 大得多:
- 文件描述符(FD)枯竭: 每个
CLOSE_WAIT都占用一个 FD(如你之前所知,0, 1, 2 是标准流,而 Socket 通常从 3 开始往后排)。一旦达到系统上限(ulimit -n),程序将无法建立任何新连接。 - 内存泄漏: 每个连接在内核中都有对应的缓冲区和控制块,大量堆积会消耗系统内存。
4. 如何排查?
当你发现 netstat 或 ss 命令显示有大量 CLOSE_WAIT 时,按以下步骤定位:
-
确认进程:
# 查看哪个进程 ID (PID) 占用了这些 CLOSE_WAIT netstat -antp | grep CLOSE_WAIT -
检查文件描述符:
# 查看该进程打开的所有 FD lsof -p <PID> -
分析代码(重点):
- 搜索代码中所有
read或recv的返回值处理。 - 必须确保: 当
read返回 0 时,或者在try...catch...finally块中,一定要显式调用close()。 - 检查是否有阻塞的操作导致程序运行不到
close()这一行。
- 搜索代码中所有
5. 总结
TIME_WAIT:我主动关的,我太快了(通常改内核参数tcp_tw_reuse)。CLOSE_WAIT:对方关了,我没反应(必须改代码)。
11.一连接一线程
在这个模型中,服务端的主线程像是一个“接待员”,而每一个新创建的线程就像是一个“专属服务员”。
1. 工作原理图解
- 主线程 (Listener Thread):在一个无限循环中调用
accept()。 - 建立连接:当客户端连接到达时,
accept()返回一个新的套接字文件描述符。 - 创建线程:主线程立即调用
pthread_create(),将新套接字交给一个新创建的子线程。 - 独立通信:子线程在自己的循环中处理
recv()和send(),直到客户端断开或发生错误。 - 销毁/回收:子线程退出,资源被回收。
2. 优缺点分析
这种模型在早期的互联网应用中非常流行,它的特点非常鲜明:
| 特性 | 描述 |
|---|---|
| 编程简单 | 逻辑直观。每个线程的代码编写方式和单客户端版本几乎一样,不需要复杂的非阻塞 I/O 状态机。 |
| 响应性好 | 一个客户端的阻塞操作(如等待大文件上传)不会影响其他客户端。 |
| 隔离性 | 如果一个处理线程因为逻辑错误崩溃,通常不会导致整个服务端进程挂掉。 |
| 资源消耗高 | 致命缺点。线程是重量级资源(Linux 下默认栈空间通常是 8MB),成千上万个线程会耗尽内存。 |
| 上下文切换开销 | 当线程数量过多,CPU 会把大量时间花在线程切换上,而不是处理业务数据。 |
3. 它的局限性:C10K 问题
“一连接一线程”在处理几百个、甚至上千个并发连接时表现尚可。但当连接数达到 10,000 (C10K) 甚至更多时,它会遇到瓶颈:
- 内存溢出:10,000 个线程 $\times$ 8MB 栈空间 = 80GB 内存。即使减小栈空间,内存压力依然巨大。
- 调度效率低:Linux 内核调度器的负担会随着线程增加而指数级上升。
4. 优化方向
如果你想继续使用线程,但又想提高效率,通常有以下两个演进方向:
A. 线程池 (Thread Pool)
不再为每个连接创建新线程,而是预先创建好一组线程(例如 100 个)。
- 当新连接到来时,放入一个任务队列。
- 空闲的线程从队列中取出连接进行处理。
- 优点:控制了资源上限,避免了频繁创建和销毁线程的开销。
B. IO 多路复用 (epoll)
这是现代高性能服务器(如 Nginx, Node.js)的选择。
- 使用单个线程同时监控成千上万个套接字的状态。
- 只有当某个套接字真的有数据可读时,才去处理它。
- 这种模式被称为 Reactor 模式。