TCP socket 编程:从 accept 到线程池,连接型服务器的第一课
我的github:(https://github.com/xcx55/ubuntu-linux-project)
感谢各位大佬参观我的github!!
UDP 那条线走完后,8 月 30 号到 9 月 1 号进入了 TCP 编程。和 UDP 相比,TCP 的接口多了一层"连接"的概念,而这一层连接带来了一整套新的问题:服务端怎么同时服务多个客户端?串行、多进程、多线程、线程池该怎么选?这篇按笔记的推进顺序整理。
一、TCP 和 UDP 编程模型的本质差异
先抓住一句话核心:UDP 是"一个 socket 文件打天下",TCP 是"一个监听文件 + 一堆连接文件"。
- UDP:数据直接写进 socket 文件里,报文自带 sockaddr_in——每个报文都"自带地址",recvfrom 一次拿数据一次拿对端地址,谁发的就回给谁;
- TCP:先 listen + connect 建立连接,然后 accept 返回一个新的 fd,后续的读写都发生在这个新 fd 上。
具体到 accept 的动作:
一个有意思的对照:TCP 和 UDP 拿取 sockaddr_in 的方式一模一样——区别只在于 UDP 是每个报文都带,TCP 是连接建立时带一次、终身有效。
另一个对照:listensockfd 一个文件服务多个 ip 和端口——它是"接客的门童",自己不运货;运货的是 accept 返回的那一个个连接 fd。
还有一条容易被忽略的底层事实:read 函数会分别判断 fd 的类型——管道、普通文件、网络文件(TCP 的文件)各走各的分支。这就是"一切皆文件"的代价与回报:接口统一,内部分流。
二、服务多个客户端:四种姿势的演进
客户端一多,问题立刻来了:如果在一个循环里串行地 ServerIO(fd, pq),一个客户端没处理完,下一个 accept 就被堵着——串行执行是阻塞的。笔记里把解决方案按历史顺序排了一遍:
1. 串行
最朴素:while(true) { accept → ServerIO(fd, pq) → close(fd); }。能用,但一次只能伺候一个人。
2. 多进程
每个客户端 fork 一个子进程去处理。要配一句 signal(SIGCHLD, SIG_IGN) 让内核自动回收子进程,否则僵尸进程堆积。但笔记里直言这样"很容易被搞挂掉"——fork 的成本摆在那。
3. 多线程
比 fork 便宜,但长服务下会线程溢出。
这里笔记里给出了一组关键概念:
- 短服务:处理逻辑短,线程滞留时间短,线程不会太溢出,“比较清爽”——平时的登录注册都是短服务;
- 长服务:一个微信要是 1000000 个客户端连过来,一个连接一个线程,服务器不得炸了?
4. 线程池
把线程数量钉死,任务进队列:
void Start()
{
signal(SIGCHLD, SIG_IGN);
threadpool<callback_t>* pa = threadpool<callback_t>::Getthreadpool();
while (true)
{
sockaddr_in pq;
socklen_t a = sizeof(sockaddr_in);
int fd = accept(_listensockfd, (sockaddr*)&pq, &a); // listen内部
if (fd < 0) { std::cout << "accept err" << std::endl; continue; }
pa->Enqueue([fd, pq, this]() -> void
{ this->ServerIO(fd, pq); });
}
}
一句话点评:接入线程池,限制了效率上限,但服务器不会挂掉——用峰值性能换稳定性,这笔账在服务端永远是划算的。
线程池版本还带来一个设计红利:回调可以在 ServerIO 里再次包装——比如处理指令就是"远程处理类",业务逻辑通过 lambda 注册进来,服务器骨架完全不用改。这正好接上了线程池那篇"回调 = 底层调用上层"的结论。
三、connect 的隐式绑定
UDP 那篇讲过 sendto 会隐式 bind,TCP 这边对称地来了一个:
connect 和 sendto 一样,会把 sockfd 进行隐式绑定——客户端不用显式 bind,connect 时若还没绑定(相当于"pa == nullptr"),就进行默认绑定并默认发送。
规则是通用的:主动发起通信的一方,端口交给 OS 随机分配。
四、地址转换函数的两个坑:inet_ntoa 为什么不可重入
inet_ntoa 把 4 字节整数 IP 转成点分十进制字符串,用起来很顺手,但它有个隐蔽的问题:
返回的 char 是函数内部 static 的!所以它是不可重入函数。*
推演一下事故现场:线程 A 调用 inet_ntoa 还没来得及使用返回值,线程 B 也调用了 inet_ntoa——static 缓冲区被覆盖,线程 A 拿到的是别人的 IP 字符串。而 inet_ntoa 没有加锁,这个坑是无声的。
正解是用这一对:
- inet_ntop():网络序整数 → 字符串(n = network,p = presentation,进程序列),多传一个缓冲区参数;
- inet_pton():字符串 → 网络序整数。
缓冲区 + (库内的)锁,同时解决了线程安全和存储安全——调用者自己的缓冲区谁也覆盖不了。这个"用调用者提供的缓冲区代替函数内 static 缓冲区"的手法,是可重入函数设计的标准范式,和信号处理那篇"不可重入函数"的讨论完全呼应。
五、小结
| socket 文件 | 一个,读写都在它 | listensockfd + 每连接一个 fd |
| 对端地址 | 每个报文自带 | accept 时拿到一次 |
| 客户端 bind | sendto 隐式绑定 | connect 隐式绑定 |
| 多客户端 | 无连接天然并发 | 串行 → 多进程 → 多线程 → 线程池 |
| 推荐的地址转换 | inet_ntop / inet_pton | 不要用 inet_ntoa(不可重入) |
TCP 编程的第一课其实不是接口,而是**"连接"这个概念给服务端带来的并发压力**——UDP 天生无连接无所谓并发模型,TCP 每一个连接都是一份要长期持有的资源,于是才有短服务/长服务的取舍和线程池的登场。
而连接维持住了之后,新的问题马上出现:TCP 发的数据是"一片一片"的,读到的东西可能半截——这就引出了下一篇的主角:序列化与自定义协议。
网硕互联帮助中心





评论前必须登录!
注册