【Linux网络编程】03 · Socket 编程 TCP(API 精讲与四种并发服务器)
摘要:本文延续前两篇的风格,把 TCP 网络编程的知识点讲透。重点包括:六个核心 API(
socket/bind/listen/accept/connect/read-write)的参数与返回值陷阱、read()返回 0 的真实含义、setsockopt的一个隐蔽写法错误、监听 socket 与连接 socket 的角色分工及内核双队列机制、以及 V1~V4 四种并发模型的演进(单进程 → 多进程 → 多线程 → 线程池)。文章在课件基础上补充了大量内核层面的解释。
关键词:TCP、listen、accept、connect、backlog、全连接队列、半连接队列、孤儿进程、pthread_detach、线程池
目录
- [一、TCP 与 UDP:编程模型的根本差异](#一、TCP 与 UDP:编程模型的根本差异)
- [二、TCP socket API 逐个拆解](#二、TCP socket API 逐个拆解)
- [三、read() / write() 返回值的三种情况](#三、read() / write() 返回值的三种情况)
- 四、setsockopt:一个隐蔽的写法错误
- [五、监听 socket、连接 socket 与队列机制](#五、监听 socket、连接 socket 与队列机制)
- [六、V1 → V4:四种并发模型演进](#六、V1 → V4:四种并发模型演进)
- [七、recv/send 与 read/write](#七、recv/send 与 read/write)
- 八、常见坑与高频面试题
一、TCP 与 UDP:编程模型的根本差异
| 对比项 | UDP | TCP |
|---|---|---|
| socket 类型 | SOCK_DGRAM |
SOCK_STREAM |
| 是否建立连接 | 无,recvfrom 直接收 |
有,需三次握手 |
| 服务端额外 API | 无 | ⭐ listen() + accept() |
| 客户端额外 API | 无 | ⭐ connect() |
| socket 数量 | 1 个 sockfd 服务所有客户端 | ⭐ 1 个监听 socket + N 个连接 socket |
| IO 函数 | recvfrom / sendto(要带地址) |
**read / write 或 recv / send(不用带地址)** |
| 数据边界 | 有(数据报) | **无(字节流,会粘包)** |
| 可靠性 | 不可靠 | 可靠、有序、不丢不重 |
💡 一句话记住:UDP 是"一个 socket 对所有人",TCP 是"一个 socket 迎宾,每人再配一个专属 socket"。
TCP 服务端骨架:
socket() → bind() → listen() → accept() [阻塞] → read/write → close(connfd)
↑ │
└──────────── 回到这里继续 accept ───┘
二、TCP socket API 逐个拆解
全部声明在 <sys/socket.h> 中。
2.1 socket()
c
int socket(int domain, int type, int protocol);
_listensock = socket(AF_INET, SOCK_STREAM, 0);
socket()打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符;- 应用程序可以像读写文件一样用
read/write在网络上收发数据; - 如果
socket()调用出错则返回-1; - 对于 IPv4,
family参数指定为AF_INET; - 对于 TCP 协议,
type参数指定为SOCK_STREAM,表示面向流的传输协议; protocol参数指定为0即可(自动推导为 IPPROTO_TCP)。
2.2 bind()
服务器程序所监听的网络地址和端口号通常是固定不变 的,客户端程序得知服务器程序的地址和端口号后就可以向服务器发起连接;服务器需要调用 bind 绑定一个固定的网络地址和端口号。
bind()成功返回0,失败返回-1;bind()的作用是将参数sockfd和myaddr绑定在一起,使sockfd这个用于网络通讯的文件描述符监听myaddr所描述的地址和端口号;struct sockaddr *是一个通用指针类型,myaddr参数实际上可以接受多种协议的 sockaddr 结构体,而它们的长度各不相同,所以需要第三个参数addrlen指定结构体的长度。
标准初始化四步:
c
struct sockaddr_in local;
memset(&local, 0, sizeof(local)); // ① 将整个结构体清零
local.sin_family = AF_INET; // ② 设置地址类型为 AF_INET
local.sin_addr.s_addr = htonl(INADDR_ANY); // ③ 网络地址为 INADDR_ANY
local.sin_port = htons(_port); // ④ 端口号为 SERV_PORT(如 9999)
关于 ③ 的原文解释:
网络地址为
INADDR_ANY,这个宏表示本地的任意 IP 地址,因为服务器可能有多个网卡,每个网卡也可能绑定多个 IP 地址,这样设置可以在所有的 IP 地址上监听,直到与某个客户端建立了连接时才确定下来到底用哪个 IP 地址。
📌 拓展(呼应 UDP 篇) :课件里写的是htonl(INADDR_ANY)。其实INADDR_ANY == 0x00000000,htonl(0) == 0,所以写或不写htonl效果完全一样。推荐直接写INADDR_ANY,语义更清晰。但千万别惯性地对inet_addr()的返回值再套htonl------ 那就真的转两次了。
2.3 listen()
c
int listen(int sockfd, int backlog);
listen()声明 sockfd 处于监听状态 ,并且最多允许有 backlog 个客户端处于连接等待状态,如果接收到更多的连接请求就忽略。这里设置不会太大(一般是 5)。
listen()成功返回0,失败返回-1;- ⭐ 这是 TCP 特有的 ,UDP 服务端不需要(也不需要
accept)。
listen() 到底做了什么?
这是理解全篇的关键:
socket() 创建出来的 socket ──listen()──> 被动监听 socket
(主动 socket) ├─ 状态变为 LISTEN
├─ 挂接【半连接队列】
├─ 挂接【全连接队列】
└─ 注册进内核「监听端口哈希表」
**只有调用
listen(),socket 的角色才从"主动发起连接"转变为"被动接受连接",内核才会为它分配那两个队列。**
2.4 accept()
c
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
- 三次握手完成后 ,服务器调用
accept()接受连接; - 如果服务器调用
accept()时还没有客户端的连接请求,就阻塞等待直到有客户端连接上来; addr是一个传出参数 ,accept()返回时传出客户端的地址和端口号;- 如果给
addr参数传NULL,表示不关心客户端的地址; - ⭐
addrlen参数是一个传入传出参数(value-result argument) :- 传入 :调用者提供的缓冲区
addr的长度,以避免缓冲区溢出问题; - 传出:客户端地址结构体的实际长度(有可能没有占满调用者提供的缓冲区)。
- 传入 :调用者提供的缓冲区
📌 这与 UDP 篇讲的
recvfrom的socklen_t *len是完全同一个套路 ,必须在调用前初始化:socklen_t len = sizeof(peer);
返回值 ⭐ 重点:
c
int sockfd = accept(_listensock, CONV(&peer), &len); // 返回一个全新的文件描述符
理解 accept 的返回值:饭店拉客的例子
- 监听 socket = 门口揽客的服务员。他永远站在门口,永远不做饭;
accept()= 服务员接到一位客人 ,把他交给一位专属服务员;- 返回值
connfd= 这位专属服务员的工号。之后所有点菜/上菜只对这位专属服务员发生;- 门口的服务员继续站在门口揽下一位客人。
2.5 connect()
c
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 客户端需要调用
connect()连接服务器; - ⭐
connect和bind的参数形式一致,区别在于bind的参数是自己的地址,而connect的参数是对方的地址; connect()成功返回0,出错返回-1;- ⭐
connect()会自动进行本地 bind(OS 随机分配临时端口 + 本机 IP)。
c
struct sockaddr_in server;
memset(&server, 0, sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
inet_pton(AF_INET, serverip.c_str(), &server.sin_addr); // p:process(进程) n:network
int n = connect(sockfd, CONV(&server), sizeof(server)); // 自动进行 bind 哦!
⚠️ 关键结论 :客户端
connect并不会产生新的 sockfd 。未来我们就用connect成功的那个 sockfd 进行通信即可。这一点和服务端accept一定产生新 fd 完全不同。
2.6 客户端也要不要 bind?
**由于客户端不需要固定的端口号,因此不必调用
bind(),客户端的端口号由内核自动分配。**
两个必须分清的注意点:
| 角色 | 是否必须 bind | 说明 |
|---|---|---|
| 客户端 | 不是不允许调用 bind(),只是没有必要显示地调用 bind() 固定一个端口号 |
否则如果在同一台机器上启动多个客户端,就会出现端口号被占用导致不能正确建立连接 |
| 服务器 | 也不是必须调用 bind() |
但如果服务器不调用 bind(),内核会自动给服务器分配监听端口,每次启动服务器时端口号都不一样,客户端要连接服务器就会遇到麻烦 |
三、read() / write() 返回值的三种情况
这是 TCP 编程中最重要的一个认知,UDP 里不存在这种情况。
c
ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
if (n > 0)
{
buffer[n] = 0;
std::cout << "client say# " << buffer << std::endl;
// ... 处理并 write 回去
}
else if (n == 0) // ⭐ read 如果返回值是 0,表示读到了文件结尾(对端关闭了连接!)
{
lg.LogMessage(Info, "client quit...\n");
break;
}
else
{
lg.LogMessage(Error, "read socket error, errno code: %d, error string: %s\n",
errno, strerror(errno));
break;
}
| 返回值 | 含义 | 处理动作 |
|---|---|---|
> 0 |
实际读到的字节数 | 正常处理(n 可能 < 你期望的长度!见下方说明) |
== 0 |
⭐ 读到了文件结尾 = 对端关闭了连接(收到了对方的 FIN) | break 退出循环,然后 close(sockfd) |
< 0 |
出错,查 errno |
一般也是 break |
💡 为什么
read到文件结尾就代表对方关闭连接?因为在 Linux 中 socket 就是一个 fd,
read到 EOF 的语义被复用了。TCP 收到对端的 FIN 包 (调用close()或shutdown(SHUT_WR)发出)后,接收缓冲区里不再有数据可读,read就返回 0。这是应用层感知"对方下线"的唯一标准方式。
⚠️ 两个易错点:
n > 0不代表一次读完了完整的一"条"消息 。TCP 是字节流,多次 send 可能被合并成一个包读到(粘包),一次 send 也可能被拆开读到。应用层必须自己定协议解决(见第七节)。- 客户端也需要判断
m == 0 || m < 0才 break,否则对方关了连接自己还在傻读。
四、setsockopt:一个隐蔽的写法错误
课件代码里有这么一行:
c
int opt = 1;
setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
4.1 ⚠️ 这行其实写错了
SO_REUSEADDR 和 SO_REUSEPORT 是两个独立的选项名常量,不是可以组合的位掩码。在 Linux 头文件里:
c
#define SO_REUSEADDR 2 // 0b00010
#define SO_REUSEPORT 15 // 0b01111
SO_REUSEADDR | SO_REUSEPORT = 2 | 15 = 15 = SO_REUSEPORT ❌
**按位或之后的结果恰好等于 SO_REUSEPORT,所以这行代码实际只设置了 SO_REUSEPORT,SO_REUSEADDR 根本没生效。**
✅ 正确写法:分开调用两次
c
int opt = 1;
setsockopt(_listensock, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
setsockopt(_listensock, SOL_SOCKET, SO_REUSEPORT, &opt, sizeof(opt));
📌 这是一个很好的教训 :凡是 "以常量名区分的 setsockopt 选项",永远一个个单独设置。用
|组合是直觉上的陷阱。
4.2 为什么要设置这两个选项?
① SO_REUSEADDR:解决 bind: Address already in use
TCP 主动关闭方会进入 TIME_WAIT 状态(持续 2MSL,通常 60 秒)。这期间端口和 IP 的组合仍然被占用。如果你调试时频繁重启服务器,第二次启动必然报:
bind socket error, errno code: 98, error string: Address already in use
设置 SO_REUSEADDR 后,允许在 TIME_WAIT 状态下重新绑定该地址,让开发调试不再等到花儿谢。
② SO_REUSEPORT:允许多个进程/线程绑定同一个 IP+Port
内核会对这些 socket 做负载均衡(Linux 3.9+)。这是 Nginx 多 worker 进程同时监听 80 端口的现代做法。
4.3 两者的对比
| 选项 | 解决的问题 | 典型场景 |
|---|---|---|
SO_REUSEADDR |
TIME_WAIT 状态下重新 bind | 服务器重启、崩溃后立即拉起 |
SO_REUSEPORT |
多个 socket 同时监听同一端口 | 多进程并发服务器,内核分担新连接 |
五、监听 socket、连接 socket 与队列机制
🔗 这一节是 TCP 编程的灵魂。 理解了它,
listen/accept/backlog/为什么不用一个 socket这些疑问会一次性全部打通。
5.1 两种 socket 的角色
| 类型 | 数量 | 作用 |
|---|---|---|
| 监听 socket | 1 个 | 只负责 listen + accept |
| 连接 socket | 多个 | 每个客户端一个,负责收发数据 |
-
_listensock:一个服务器对象只保存一个,负责监听。 -
connfd(连接 socket) :可以有任意多个,每个客户端一个,由accept()返回。每个连接 socket 在内核里都有自己独立的收发缓冲区,数据就是通过这两个缓冲区中转的。 -
backlog:控制内核队列里能排多少个"等待被 accept 的连接"。┌──────────────────────────────────┐ │ 服务器进程 │ │ │ _listensock ──────┤ 只用于 listen / accept │ │ │ connfd_1 ─────────┤ 客户端 A 专属 <─> 独立收发缓冲区 │ connfd_2 ─────────┤ 客户端 B 专属 <─> 独立收发缓冲区 │ connfd_3 ─────────┤ 客户端 C 专属 <─> 独立收发缓冲区 │ └──────────────────────────────────┘
5.2 客户端与服务端 socket 的关系
一条 TCP 连接建立后,两端各有一个 socket:
客户端:client_fd = socket() + connect()
服务端:conn_fd = accept(listen_fd)
client_fd 和 conn_fd 是两个不同主机上的 socket,但它们共同组成同一条 TCP 连接。
TCP 用 四元组 唯一标识一条连接:
(客户端IP, 客户端端口, 服务器IP, 服务器端口)
💡 拓展 :正因为有这四元组,服务器才有可能用一个端口同时服务成千上万个客户端 ------ 它们的客户端 IP/端口各不相同,内核靠四元组就能把数据分发到正确的
connfd。这也是_listensock只需要一个的根本原因。
5.3 连接 socket 是谁创建的
⭐ **连接 socket 不是客户端提供的,而是服务端内核在
accept()时创建的。**
客户端那边也有自己的 socket,但它是客户端进程里的 socket,不是服务端队列里的那个。
5.4 listen 时队列里放的是什么
调用 listen() 后,内核会为监听 socket 维护两个队列:
半连接队列(SYN Queue):
收到客户端 SYN,但还没完成三次握手
这里放的是"连接请求记录",还不是完整 socket
全连接队列(Accept Queue):
三次握手已经完成
这里放的是已经建立好的连接,accept() 会从这里取
完整链路:
客户端 connect()
-> 发送 SYN
服务端收到 SYN
-> 放入半连接队列
-> 回复 SYN+ACK
客户端回复 ACK
-> 从半连接队列移到全连接队列
服务端 accept()
-> 从全连接队列取出
-> 内核创建连接 socket
-> 返回 connfd
配上三次握手的时间线会更清楚:
客户端 服务端内核
│ │
│──────── SYN ────────────────────>│ ① 创建 request_sock,放入【半连接队列】
│ │
│<──────── SYN + ACK ──────────────│ ②
│ │
│──────── ACK ────────────────────>│ ③ 握手完成!
│ │ 取出半连接项 → 构建完整 sock
│ │ → 放入【全连接队列】
│ │
│ │ ④ accept() 从全连接队列取出
│ │ → 分配 fd → 返回 connfd
▼ ▼
💡 拓展:
backlog的真实含义在 Linux 2.2 之后 ,
backlog指的是**全连接队列(accept queue)** 的最大长度。
全连接队列实际长度 =
min(backlog, /proc/sys/net/core/somaxconn),somaxconn默认 128(新内核 4096);半连接队列长度由
/proc/sys/net/ipv4/tcp_max_syn_backlog控制(默认 1024);查看与调整:
bashcat /proc/sys/net/core/somaxconn cat /proc/sys/net/ipv4/tcp_max_syn_backlog cat /proc/sys/net/ipv4/tcp_syncookies # SYN Flood 防御开关SYN Flood 攻击 的原理就是疯狂发 SYN 但不回 ACK,把半连接队列打满,让正常用户连不上。Linux 的
syncookies机制可以在队列满时不分配资源而是通过 cookie 校验来抵抗它。
5.5 为什么不能没有监听 socket
**队列确实是挂在监听 socket 对应的内核对象上的,但监听 socket 本身不能省掉。**
① 队列需要一个"主人"
内核必须知道 SYN 是发给哪个端口的,该把连接放进哪个队列。
② listen() 才改变角色
socket() 创建的是主动 socket ,调用 listen() 后才变成被动监听 socket,内核才会分配队列。
③ 端口绑定需要对象承载
bind() 把 IP+端口绑定到 socket 上,监听 socket 是这个端口在用户空间的代表。
5.6 监听 socket 后续还会被用到
监听 socket 设置完监听状态后,**不是"设置完就扔"**:
- ⭐
accept()每次都要传_listensock,内核通过它找到全连接队列; - 监听 socket 不会调用
read()/write(),只参与bind、listen、accept; - 服务器整个生命周期里,
_listensock一直活着,反复被accept()调用。
c
void Start()
{
_isrunning = true;
while (_isrunning)
{
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sockfd = accept(_listensock, CONV(&peer), &len); // ⭐ 每次都用同一个 _listensock
if (sockfd < 0) { /* 打日志,continue(不能退出!)*/ continue; }
ProcessConnection(sockfd, peer); // 交给"别人"去服务
}
}
💡 注意
accept失败要用continue而不是exit------ 一次 accept 失败(比如被信号中断返回 EINTR)不代表服务器该死。这里用的是Warning级别日志。
5.7 一句话总结
_listensock = 一个监听端口的内核对象句柄
队列 = 这个对象上的两个附属数据结构(半连接队列 + 全连接队列)
accept = 通过这个句柄访问它的已完成连接队列,内核创建新的 connfd 返回
_listensock 的作用不只是"提供挂接队列的空间",它还是监听端口的内核代表对象,负责:
- 承载 bind 的 IP + 端口
- 标记 LISTEN 状态
- 在内核监听端口哈希表中注册
- 挂接两个队列
- 作为
accept()的入口
六、V1 → V4:四种并发模型演进
6.1 V1 单进程版 ------ 为什么不科学
原生 V1 的 Start():
c
int sockfd = accept(_listensock, CONV(&peer), &len);
if (sockfd < 0) { /*...*/ continue; }
Service(sockfd); // ⚠️ 在这里死循环 read,直到客户端退出
close(sockfd);
测试结果:
再启动一个客户端,尝试连接服务器,发现第二个客户端不能正确的和服务器进行通信。
分析原因,是因为我们 accept 了一个请求之后,就在一直 while 循环尝试 read,没有继续调用到 accept,导致不能接受新的请求。
图解:
时间线 ────────────────────────────────────────────────>
主线程: accept() → Service(A) 里 while(read) 卡住 ─────────────────┐
│ 永远不会回到 accept
客户端B: connect() 成功!✓(三次握手是内核完成的)
发数据 → 进了 connfd 的接收缓冲区
拿不到任何回复 ✗ ↑
没人来 read 它
💡 一个反直觉的点 :第二个客户端
connect()是能成功的 !因为三次握手是内核自动完成 的,不需要服务器进程参与(内核收到 SYN 就自己回 SYN+ACK 了)。所以客户端会连上、能发数据,但永远等不到回复------这些数据静静躺在全连接队列里那个连接的接收缓冲区中。
6.2 V2 多进程版
通过每个请求创建一个子进程的方式来支持多连接。
c
void ProcessConnection(int sockfd, struct sockaddr_in &peer)
{
pid_t id = fork();
if (id < 0)
{
close(sockfd);
return;
}
else if (id == 0)
{
// child
close(_listensock); // ① 子进程不需要监听
if (fork() > 0) // ② 二次 fork,父(子)进程立刻退出
exit(0);
InetAddr addr(peer);
lg.LogMessage(Info, "process connection: %s:%d\n", addr.Ip().c_str(), addr.Port());
// 孙进程,孤儿进程,被系统领养,正常处理
Service(sockfd);
close(sockfd);
exit(0);
}
else
{
close(sockfd); // ③ 父进程必须关掉 connfd
pid_t rid = waitpid(id, nullptr, 0); // 立刻回收刚退出的子进程
}
}
三个精妙之处
① 父进程为什么要 close(sockfd)?
fork() 时子进程复制了父进程的 fd 表 ,connfd 在两个进程里各有一份。而 socket 的关闭遵循引用计数:只有所有指向它的 fd 都 close,连接才真正关闭并发 FIN。
如果父进程不关:
父进程fd表: [_listensock=3][connfd_A=4][connfd_B=5][connfd_C=6]...
↑ 每个子进程也会复制一份!
- ❌ 父进程 fd 表会无限增长 ,最终
too many open files; - ❌ 子进程
close(sockfd)后连接不会真正关闭(父进程还持有一份),客户端收不到 FIN ,read()永远不返回 0。
② 子进程为什么要 close(_listensock)?
子进程只负责服务这个客户端,不需要也不应该去 accept。关掉可以省一个 fd,更重要的是防止子进程误操作影响监听。
③ 为什么要二次 fork(if (fork() > 0) exit(0);)?
这是最巧妙的一步。目的是让父进程不必等待"服务结束"那么久:
fork()
├── 子进程 id==0
│ └── 再 fork()
│ ├── 孙进程(真正在这里 fork 返回 0)→ Service(sockfd) 干活
│ └── 子进程的 fork 返回 >0 → 立即 exit(0)
│
└── 父进程 id>0 → waitpid(id) 立刻返回(因为子进程马上就退出了)
→ 主线程马上回到 accept,响应速度极快 ✓
子进程退出后,孙进程变成【孤儿进程】,被 1 号 init/systemd 进程领养,
它退出时由系统自动回收资源(不会变僵尸)。
💡 另一种更简洁的替代方案(不用二次 fork):
csignal(SIGCHLD, SIG_IGN); // 让内核自动回收子进程,不产生僵尸进程或者用
waitpid(-1, NULL, WNOHANG)非阻塞轮询。
多进程方案评价
| 优点 | 缺点 |
|---|---|
| 代码相对简单 | fork() 开销大(复制页表),高并发下性能差 |
| 进程间天然隔离,一个崩了不影响其他 | 进程间通信麻烦(共享数据要用 IPC) |
| 稳定性好 | 能支撑的并发数有限(几百个) |
6.3 V3 多线程版
c
class ThreadData
{
public:
ThreadData(int sockfd, struct sockaddr_in addr) : _sockfd(sockfd), _addr(addr) {}
~ThreadData() {}
public:
int _sockfd;
InetAddr _addr;
};
static void *threadExcute(void *args) // ⭐ 为什么是 static?
{
pthread_detach(pthread_self()); // ⭐ 为什么要 detach?
ThreadData *td = static_cast<ThreadData *>(args);
TcpServer::Service(*td);
close(td->_sockfd);
delete td;
return nullptr;
}
void ProcessConnection(int sockfd, struct sockaddr_in &peer)
{
InetAddr addr(peer);
pthread_t tid;
ThreadData *td = new ThreadData(sockfd, peer); // ⭐ 为什么要 new?
pthread_create(&tid, nullptr, threadExcute, (void*)td);
}
三个必答问题
① ThreadData 为什么必须 new(堆上分配)?
c
// ❌ 错误:局部变量
void ProcessConnection(int sockfd, struct sockaddr_in &peer) {
ThreadData td(sockfd, peer); // 在栈上
pthread_create(&tid, nullptr, threadExcute, (void*)&td);
} // 函数返回,td 被销毁 → 新线程拿着野指针访问 = 未定义行为!
因为
ProcessConnection会立刻返回 (要赶紧回去 accept),而新线程可能还在使用td。栈对象随函数返回销毁,所以必须在堆上 new ,由 thread 函数内部delete。
② Service / threadExcute 为什么要加 static?
pthread_create 要求的函数签名是 void *(*)(void *) ------ 一个普通 C 函数指针。
而非静态成员函数的真实签名是 void *(TcpServer::*)(void *) ------ 它隐含了 this 指针作为第一个参数。两者类型不匹配,编译不过。
static 成员函数没有 this 指针,签名正好匹配。
💡 如果需要访问成员变量怎么办?常见做法是把
this一起打包进 ThreadData ,或者直接用 C++11 的std::thread+ lambda(内部会处理 this)。
③ 为什么要 pthread_detach(pthread_self())?
pthread 线程默认是 joinable 状态:退出后资源(线程描述结构、栈)不会释放,必须有人 pthread_join 。主线程在忙 accept,没空 join 每一个。
**detach = 告诉系统"我结束时你自己回收"**,线程退出后资源自动释放,不需要 join。
| 状态 | 是否需要 join | 资源回收方式 |
|---|---|---|
| joinable(默认) | 必须 | pthread_join() 时回收 |
| detached | 不能 join | 线程结束时自动回收 |
多线程 vs 多进程
| 多进程 | 多线程 | |
|---|---|---|
| 创建开销 | 大(复制地址空间) | 小(共享地址空间) |
| 切换开销 | 大(要切页表、TLB 失效) | 小 |
| 隔离性 | 好(互不干扰) | 差(一个崩了全崩) |
| 数据共享 | 麻烦(需 IPC) | 天然共享(但要加锁) |
| 适用 | 稳定优先 | 性能优先 |
⚠️ 因为线程共享地址空间,
Service()里如果有全局数据(比如在线用户列表),必须加锁。
6.4 V4 线程池版
**引入系统部分的线程池,进行简单的业务处理。**
c
void ProcessConnection(int sockfd, InetAddr addr)
{
// 把任务丢给线程池,主线程立即返回继续 accept
auto tp = ThreadPool<task_t>::GetInstance();
task_t t = std::bind(&TcpServer::Service, this, sockfd, addr);
tp->Enqueue(t);
}
四种模型横向对比:
| 版本 | 模型 | 能并发? | 缺点 |
|---|---|---|---|
| V1 | 单进程 | ❌ | accept 一个后就卡在 read 里 |
| V2 | 多进程(每连接一进程) | ✅ | fork 开销大,不适合高并发 |
| V3 | 多线程(每连接一线程) | ✅ | 线程数随连接数线性增长,C10K 问题 |
| V4 | 线程池(预先创建固定数量线程) | ✅ | 实现复杂;短连接场景优势不明显 |
⭐ 核心思想 :把 "接受连接" (主循环)和 "服务连接" (业务)分到不同的执行流 ------ 这与 UDP 篇引入线程池的道理完全一样。
📌 到这里你会发现,这条路往下走就是
select/poll/epoll的多路转接模型(本系列下一篇),它能用一个线程监视上万个连接,彻底解决 C10K 问题。
6.5 插曲:V3-1 远程命令执行
一个小业务,演示 popen 的用法:
c
std::string Execute(const std::string &command)
{
if (!IsSafe(command)) return "unsafe"; // ⭐ 白名单机制
FILE *fp = popen(command.c_str(), "r"); // 执行命令并读它的 stdout
if (fp == nullptr) return std::string();
char buffer[1024];
std::string result;
while (fgets(buffer, sizeof(buffer), fp))
result += buffer;
pclose(fp); // ⚠️ 必须配对
return result;
}
| 知识点 | 说明 |
|---|---|
popen(cmd, "r") |
创建管道 + fork + exec,返回 FILE* 用于读取命令输出 |
pclose(fp) |
关闭并等待子进程,必须和 popen 配对,否则僵尸进程 |
白名单 _safe_command |
⭐ 只放行 ls/pwd/whoami/touch 等命令,这是重要的安全实践。直接把网络数据当 shell 执行 = 严重远程代码执行漏洞 |
💡 另外注意:
touch这类命令没有 stdout 输出,Execute会返回空串,所以SendCommand里做了兜底:
cif (result.empty()) result = "done";
七、recv/send 与 read/write
V3-1 里用的是 recv / send:
c
ssize_t n = recv(_sockfd, line, sizeof(line) - 1, 0);
send(_sockfd, result.c_str(), result.size(), 0);
而 echo 版本用的是 read / write。它们有什么区别?
c
ssize_t read (int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
| 对比 | read/write | recv/send |
|---|---|---|
| 适用对象 | 所有 fd(文件、管道、socket) | 仅 socket |
flags 参数 |
无 | ⭐ 有,可传 MSG_PEEK(偷看不取走)、MSG_DONTWAIT(非阻塞)、MSG_NOSIGNAL(忽略 SIGPIPE)等 |
| 本质 | 通用文件 IO | socket 专用,功能更丰富 |
结论:
- 只做普通收发时,
read/write就够了(flags传 0 的 recv/send 与之完全等价); - 需要特殊控制时才用
recv/send,比如MSG_NOSIGNAL可以避免向已关闭的 socket 写数据时进程被SIGPIPE杀死 ------ 这是服务器开发必备的一招。
⭐ 关于粘包(本篇必须提一句)
课件 V3-1 注释里写着:
// 因为我们尚未学习如何定制协议,所以这里暂时这样写
指的就是没有处理粘包。TCP 是字节流:
发送方: send("hello") send("world")
↓
接收方: recv() 可能一次读到 "helloworld",也可能读到 "hel" + "loworld"
解决方案(下一篇详解) :应用层自己定协议。最简单的是 "包头定长 + 包体" :
┌──────────┬────────────────────────┐
│ 4字节长度 │ 实际数据(N字节) │
└──────────┴────────────────────────┘
接收逻辑:先读 4 字节得到 N,再循环读满 N 字节,才算一条完整消息。
八、常见坑与高频面试题
8.1 必踩的坑清单
| 坑 | 症状 | 解法 |
|---|---|---|
| V1 单进程卡在 Service | 第二个客户端连上但没响应 | 必须 fork / pthread / 线程池 |
fork 后父进程忘 close(connfd) |
fd 泄漏;客户端收不到 FIN,read 不返回 0 |
父子各自关掉不需要的 fd |
| ThreadData 用栈变量 | 随机崩溃、数据错乱 | 堆上 new,线程里 delete |
忘了 pthread_detach |
线程资源不回收,跑久了内存涨到无法创建线程 | pthread_detach 或让它 joinable 后统一管理 |
| **`SO_REUSEADDR | SO_REUSEPORT` 连用** | 实际只生效一个 |
重启报 Address already in use |
TIME_WAIT 占用 | 设置 SO_REUSEADDR |
accept 失败直接 exit |
被信号打断就整个服务挂掉 | 记日志 + continue |
socklen_t len 忘初始化 |
客户端地址读不出来 | socklen_t len = sizeof(peer); |
忽略 read 返回 0 |
客户端下线了服务器还在死循环 | n==0 必须 break |
| 向已关闭 socket 写数据 | 进程被 SIGPIPE 杀死 | send(..., MSG_NOSIGNAL) 或 signal(SIGPIPE, SIG_IGN) |
8.2 高频面试题
| 问题 | 答案要点 |
|---|---|
| 为什么 TCP 需要 listen/accept 而 UDP 不需要? | TCP 面向连接,需先三次握手建立连接,accept 从已完成队列取出并创建 connfd |
accept 返回的 fd 和监听 fd 有什么区别? |
监听 fd 只用于 listen/accept;返回的 connfd 是新的,代表与特定客户端的连接,用于 read/write |
listen 的 backlog 是什么含义? |
Linux 2.2 之后指全连接队列长度,实际取 min(backlog, somaxconn) |
| 半连接队列和全连接队列分别放什么? | 半连接:收到 SYN 未完成握手的请求;全连接:已完成三次握手等待 accept 的连接 |
| 三次握手在什么时候完成?需要 accept 参与吗? | 不需要。三次握手完全由内核完成,accept 只是事后取走结果 |
| 为什么 V1 单进程第二个客户端 connect 能成功却没响应? | connect 由内核完成握手;但主线程卡在第一个连接的 read 循环里,没人 read 第二个连接的数据 |
read 返回 0 是什么意思? |
对端关闭连接(收到 FIN),应 close 并结束服务 |
| 多进程版为什么要 fork 两次? | 让父进程立即回收子进程,孙进程成孤儿被 init 领养自动回收,主线程快速回到 accept |
| 多线程版 Service 为什么要 static? | 非静态成员函数隐含 this 指针,签名不匹配 void*(*)(void*) |
SO_REUSEADDR 和 SO_REUSEPORT 的区别? |
前者解决 TIME_WAIT 下重新 bind;后者允许多个 socket 监听同一端口做负载均衡 |
| 为什么 TCP 会有粘包而 UDP 不会? | TCP 面向字节流无边界;UDP 面向数据报,一次 sendto 对应一个完整报文 |
| 一个端口能建立多少条连接? | 理论上是「客户端 IP 数 × 客户端端口数」,因为四元组标识一条连接,服务端端口可以复用 |
参考
-
前两篇:【Linux网络编程】01 · 网络基础概念 / 02 · Socket 编程 UDP
-
《UNIX 网络编程 卷1:套接字联网 API》
-
《TCP/IP 详解 卷1:协议》------ 三次握手与状态机
-
《Linux 高性能服务器编程》------ 游双,四种并发模型与 IO 复用
-
Linux man 手册:
bashman 2 listen man 2 accept man 2 connect man 2 read man 7 socket man 2 setsockopt
系列回顾
01 网络基础概念 → 分层、地址、字节序、协议本质
↓
02 Socket 编程 UDP → socket/bind/recvfrom/sendto,回调解耦,线程池
↓
03 Socket 编程 TCP → listen/accept/connect,双队列机制,四种并发模型
↓
04 应用层协议定制 → 解决 TCP 粘包,序列化/反序列化,Json/Protobuf
↓
05 多路转接 select/poll/epoll → C10K,Reactor 模式
↓
...
本篇最需要记住的三句话:
- "监听 socket 是门口揽客的服务员,连接 socket 是每位客人的专属服务员。"
- "三次握手不需要 accept 参与,accept 只是从全连接队列里把现成的连接取出来。"
- "read 返回 0 就是对端下线了。"