Linux网络编程:Linux Socket TCP

文章目录

从饭店接客到线程池|TCP Socket 编程进化实战博客

前言

很多同学初学网络编程,上来就死记硬背:socket‑bind‑listen‑accept‑connect 这套调用顺序。

能写出跑通的回声服务器,但是一问底层就懵:

监听套接字和连接套接字到底有啥不一样?

fork 之后为什么要乱七八遭 close 一堆 fd?

两次 fork 意义何在?SIGCHLD、僵尸进程、SIG_IGN 又是什么?

为什么 write 居然可以发送网络数据?

本篇博客就用饭店经营模型贯穿全文,从最基础 API,一步步迭代 4 版服务器,把网络编程常见底层问题全部讲透。

Linux 哲学:一切皆文件 。Socket 本质也是文件描述符,read/write 可以像读写普通文件一样收发网络数据。

一、TCP Socket 核心API总览(打电话/开饭店模型)

TCP 是面向连接、可靠、字节流协议,通信前必须完成三次握手建立连接。

服务端 作用 客户端 作用
socket() 创建套接字(买手机) socket() 创建套接字
bind() 绑定IP+端口(绑定门店地址) --- 一般不手动bind,内核自动分配随机端口
listen() 设置为监听套接字(开门营业) connect() 拨号发起TCP连接
accept() 取出完成三次握手的连接(接待客人入座) --- ---
read/write 收发业务数据 read/write 收发业务数据
close 关闭套接字,触发四次挥手 close 关闭套接字

重点区分两个套接字,90%初学者混淆在这里:

  1. 监听套接字 listen‑fdsocket()+bind()+listen() 得到。只给 accept() 使用,不能read/write;服务器全程存活,直到程序退出才关闭。
  2. 连接套接字 conn‑fdaccept() 的返回值。专门和单个客户端做读写IO;客户端断开就要close。
    饭店比喻:

listen‑fd = 门口迎宾,只管接待新客人,不上菜;

conn‑fd = 包间服务员,只负责和一桌客人聊天上菜。

1. socket() 创建套接字

复制代码
int socket(int domain, int type, int protocol);
  • AF_INET:IPv4;SOCK_STREAM:TCP字节流;protocol填0自动匹配协议
  • 返回值:文件描述符fd;失败返回‑1。

2. bind()绑定地址端口

复制代码
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

把 socket 和本机IP、端口绑定。

复制代码
struct sockaddr_in local;
memset(&local,0,sizeof(local));
local.sin_family = AF_INET;
local.sin_port = htons(8888);        // 主机序转网络大端序
local.sin_addr.s_addr = htonl(INADDR_ANY); // 监听本机所有网卡IP
  • INADDR_ANY:多网卡服务器,全部网卡上监听,不用写死某个IP。
  • htons / htonl:网络通信必须用大端字节序

小知识:客户端不是不能bind,只是没必要。connect的时候OS自动分配临时随机端口;手动bind容易端口冲突。

3. listen() 开启监听

复制代码
int listen(int sockfd, int backlog);

⚠️ listen 不会阻塞! 只是告诉内核:这个fd是被动监听套接字,请为我维护连接队列。

内核维护两条队列:

  1. 半连接队列(SYN队列):收到SYN,未完成三次握手
  2. 全连接队列(accept队列) :三次握手全部完成,等待accept()系统调用取走连接。

backlog 用来限制全连接队列最大长度

/proc/sys/net/core/somaxconn 是系统上限,程序填写的backlog不能超过它。
坑:服务器如果迟迟不调用accept,全连接队列打满,新来的连接会被拒绝。

4. accept() 取已经握手完成的连接

复制代码
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
  • 阻塞行为:全连接队列为空时,accept阻塞等待新连接到来
  • 返回全新的连接套接字conn‑fd,这个fd用来和客户端通信。
  • addr是传出参数,输出对端客户端IP和端口;不关心可以传NULL。

重要认知:三次握手是内核完成的,用户进程阻塞在accept的时候,客户端照样完成握手,连接放在内核队列排队。

5. connect() 客户端发起连接

复制代码
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

客户端向服务端发起TCP连接,阻塞直到握手成功或者超时失败。

read / write vs recv / send

Linux一切皆文件,socket是fd,所以read/write可以直接收发网络数据。

send(fd, buf, len, 0)等价writerecv(fd,buf,len,0)等价read

send/recv多flags参数,可以实现MSG_OOB、MSG_DONTWAIT等高级特性。

⚠️网络IO和普通文件IO行为不一样:会出现短读、短写 ,实际工程需要封装readn/writen循环读写,教学Demo为简化没有处理。

二、V1:单进程回声服务器(夫妻老婆店)

逻辑:循环accept,拿到连接之后,就在主线程做read/write服务客户端。

伪代码:

复制代码
_listensock = socket(...);
bind(_listensock,...);
listen(_listensock, backlog);

while(_isrunning){
    int connfd = accept(_listensock,...);
    Service(connfd);   // while循环read/write,阻塞在这里
    close(connfd);
}

致命缺陷:同一时刻只能服务一个客户端。

主线程陷在Service的IO循环,再也无法执行accept,新的客户端只能在全连接队列排队等待。

饭店比喻:老板既迎宾又上菜,接待一桌客人之后就被困在包间,门口来新顾客没人接待。
解决思路:把「等待新连接accept」和「客户端IO读写」两件事分开,不能放在同一个执行流阻塞。

三、V2:多进程版本服务器(fork + 两次fork)

目标:支持多客户端并发连接。父进程专门accept,每来一个客户端创建进程专门服务。

核心代码片段

复制代码
int connfd = accept(_listensock, CONV(&peer), &len);
pid_t id = fork();
if(id < 0){
    close(connfd);
    return;
}
else if(id == 0){
    close(_listensock);        // ✨子进程关闭监听套接字!
    if(fork() > 0) exit(0);    // 两次fork
    // 孙子进程真正干活
    Service(connfd);
    close(connfd);
    exit(0);
}
else{
    close(connfd);             // ✨父进程关闭通信套接字
    waitpid(id, nullptr,0);
}

🧑‍💼父进程职责

  1. 循环阻塞在accept,接收新客户端连接。
  2. 拿到connfd之后fork创建第一层子进程。
  3. 父进程不再业务通信,立刻close(connfd)
  4. waitpid回收第一层子进程,防止僵尸。
  5. 回到循环继续accept下一个连接。

👶第一层子进程(过渡进程,不干活)

  1. close(_listensock):子进程不需要监听fd。>

fork会复制文件描述符,内核socket引用计数+1;不用的fd主动关闭,避免资源泄露;防御性编程,防止子进程误调用accept。

  1. 再fork,生成孙子进程;自己马上exit退出。

👶👶孙子进程(真正执行业务IO)

继承connfd,执行Service做read/write和客户端通信;业务结束exit退出。

第一层子进程已经退出,孙子变成孤儿进程 ,会被systemd/init收养。init进程会自动wait孤儿,孙子退出自动回收,不会产生僵尸进程

为什么要两次fork?

假设只fork一次,子进程直接执行业务:

复制代码
if(id ==0){
    close(_listensock);
    Service(connfd); exit(0);
}else{
    close(connfd);
    waitpid(id,nullptr,0); // 阻塞!
}

waitpid(id, nullptr,0)是阻塞调用。

如果客户端长连接一直不关闭,子进程不退出,父进程卡在waitpid,无法继续accept接收新连接,服务器再次废掉

两次fork巧妙解决:

  1. 第一层子进程fork完孙子立刻exit;
  2. 父进程waitpid很快回收第一层子进程,父进程解除阻塞,可以回去继续accept;
  3. 孙子作为孤儿进程,想运行多久运行多久,由init负责回收。

多进程模型优缺点

✅优点:进程内存完全隔离;某个服务客户端的进程崩溃,不影响父进程和其他客户端。

❌缺点:fork开销大,拷贝页表、PCB;上万并发创建大量进程,系统压力巨大;进程间通信复杂。

四、拓展:僵尸进程与 SIGCHLD 信号(两套解决方案)

什么僵尸进程

子进程已经终止退出,PCB进程控制块还保留在内核,等待父进程wait/waitpid读取退出状态。

PCB保存退出码,是操作系统设计:父有权限知道子进程是正常退出还是异常崩溃。父不调用wait,子就变成僵尸,占用PID资源。

子进程终止,内核发送 SIGCHLD 信号给父进程。

方案1:两次fork(上面V2代码)

孙子变成孤儿,被init收养;init内部会自动wait所有孤儿,不会僵尸。

优点:POSIX标准,跨平台可移植;缺点:代码繁琐。

方案2:signal(SIGCHLD, SIG_IGN);

Linux特有扩展行为,不是POSIX强制标准!

复制代码
signal(SIGCHLD, SIG_IGN);

父进程设置忽略SIGCHLD信号。内核理解为:父完全不关心子进程退出状态

子进程终止,内核直接释放全部资源,不保存PCB,不会产生僵尸进程

⚠️注意区分:SIGCHLD的默认动作SIG_DFL也是忽略信号,但不会自动回收子进程,照样产生僵尸!

SIG_IGN触发内核特殊分支,仅此一家,别的信号没有这个待遇。

缺点:无法获取子进程退出状态;Solaris等其他UNIX不支持,可移植性差。

方案3:注册SIGCHLD信号处理函数(工业级推荐)

复制代码
void sigchld_handler(int sig){
    // WNOHANG非阻塞,循环收割所有已经死掉的子进程
    while(waitpid(-1, nullptr, WNOHANG) >0);
}
signal(SIGCHLD, sigchld_handler);

收到SIGCHLD信号触发回调,循环非阻塞waitpid收割僵尸。

坑:信号会打断阻塞系统调用。父进程正在accept收到SIGCHLD,accept返回‑1,errno=EINTR;代码中需要捕获EINTR,continue重新accept。

三种方案对比表

方案 原理 优点 缺点
两次fork 子进程fork后退出,孙子成为孤儿由init回收 标准可移植;可拿到第一层子进程状态 代码复杂,多一次fork
signal(SIGCHLD,SIG_IGN) Linux内核特例,子终止直接释放资源 代码一行极简 Linux专属;拿不到退出码;可移植差
SIGCHLD信号回调+WNOHANG 收到信号循环非阻塞waitpid 跨平台,可以拿到子退出状态 需要处理EINTR被信号打断的问题

五、V3:多线程TCP服务器(雇服务员代替开分店)

进程太重,创建开销大;线程共享进程地址空间,开销更小。

逻辑:主线程循环accept;每来一个连接,pthread_create创建新线程专门处理IO。

关键点

  1. pthread_create的入口函数必须是static静态函数,C++成员函数隐含this指针,不匹配函数签名。
  2. 传递参数不能用栈变量!要用new堆上分配结构体,线程内部delete释放,防止主线程覆盖数据。
  3. pthread_detach(pthread_self()):线程分离,退出自动回收资源,不需要pthread_join等待。

伪代码:

复制代码
struct ThreadData{
    int _sockfd;
    InetAddr _addr;
};

static void* threadExcute(void* args){
    pthread_detach(pthread_self());
    ThreadData* td = static_cast<ThreadData*>(args);
    Service(*td);
    close(td->_sockfd);
    delete td;
    return nullptr;
}

void ProcessConnection(int sockfd, struct sockaddr_in &peer){
    ThreadData* td = new ThreadData(sockfd, peer);
    pthread_t tid;
    pthread_create(&tid, nullptr, threadExcute, td);
}

✅优点:线程创建销毁开销远小于进程;同进程内共享数据方便。

❌缺点:线程共享地址空间;一个线程崩溃,整个进程直接退出;大量短连接频繁创建销毁线程,有性能损耗。

饭店比喻:不开分店,店内雇佣多名服务员;共用厨房资源,但一个服务员闯大祸整个门店关门。

六、V4:线程池版本服务器(提前招聘固定服务员)

痛点:短连接场景,来一个连接创建销毁一次线程,开销高。

线程池:程序启动预先创建一批工作线程;新连接到来,把业务任务打包扔进任务队列,空闲线程取出任务执行。不需要动态创建线程。

伪代码:

复制代码
void ProcessConnection(int sockfd, struct sockaddr_in &peer){
    InetAddr addr(peer);
    auto func = std::bind(&TcpServer::Service, this, sockfd, addr);
    ThreadPool::GetInstance()->Push(func);
}

✅优点:避免频繁创建销毁线程;可以限制最大并发数量,防止连接暴涨把系统打崩;响应速度快。

❌缺点:代码复杂度上升;任务队列需要加锁同步;任务积压会造成客户端等待。

饭店比喻:提前招好固定数量服务员,客人来了直接分配,不临时招人;高峰期客人过多只能排队。

七、四个服务器模型总对比

模型 并发单元 创建开销 崩溃影响 编程难度 适用场景
V1单进程 极小 整个服务卡住 ★☆☆☆☆ 教学Demo,仅学习
V2多进程 进程 仅故障子进程,主进程无恙 ★★★☆☆ 并发量不大,高隔离要求场景
V3多线程 线程 中等 整个进程崩溃 ★★★☆☆ 中小并发长连接
V4线程池 预创建线程 整个进程崩溃 ★★★★☆ 大量短连接,线上业务

演进底层逻辑:

  1. 分离「接收新连接accept」和「业务IO」,不能阻塞在同一个流程。
  2. 不断追求更轻量的并发单元;频繁创建代价大就预先池化。

八、网络编程高频踩坑清单

1. Address already in use

服务器Ctrl+C重启报端口占用。TCP断开之后会进入TIME_WAIT状态,端口一段时间不能复用。

解决:创建socket之后设置套接字选项

复制代码
int opt = 1;
setsockopt(fd, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));

开发环境必加。

2. fork之后fd的处理准则

fork复制文件描述符,内核文件引用计数+1。

不用的fd,一定要主动close,不要依赖进程退出自动回收。

多进程服务器记忆口诀:

父进程:保留 listen‑fd,close(conn‑fd)

子进程:close(listen‑fd),保留 conn‑fd

3. read返回0

不是读取0字节数据;代表对端关闭TCP连接(读到EOF),需要关闭本端socket。

4. SIGPIPE信号

已经断开的socket继续write,会触发SIGPIPE,默认杀死整个进程。生产环境一般忽略SIGPIPE。

5. TCP字节流无边界(粘包问题)

TCP是字节流,没有数据包边界。send两次"hello",对方可能一次读到"hellohello"。

解决方案:应用层协议,定长包 / 分隔符 / 头部携带报文长度。Demo没有处理粘包,只能用于学习。

6. 信号打断系统调用EINTR

accept/read等阻塞系统调用,收到信号会返回‑1,errno=EINTR;代码需要判断,重新发起系统调用。

九、后续学习路线

本文全部是每个连接分配一个进程/线程 的阻塞IO模型。真实高并发服务器(Nginx、Redis)不会这么做。

下一步学习:

  1. IO多路复用 select / poll
  2. epoll(Linux),Reactor事件驱动模型
  3. 非阻塞socket

阻塞多进程/多线程模型,理解清楚,是学习epoll的前置基础。万丈高楼平地起。

相关推荐
是个西兰花1 小时前
UDP套接字编程
运维·服务器·网络
顶点多余1 小时前
9.16 面试总结
linux·面试·职场和发展
其实防守也摸鱼1 小时前
堆叠注入(Stacked Injection)详解
服务器·数据库·windows·https·ssl
郝学胜-神的一滴1 小时前
C++20模板元编程 05:吃透变参模板,解锁编译期万能参数能力
服务器·开发语言·c++·windows·vscode
Wang's Blog2 小时前
Java 接入Redis: 五大数据类型与存储结构选型
java·服务器·redis
Wang's Blog2 小时前
Java 接入Redis: 字符串与哈希类型操作命令
java·服务器·redis
是店小二呀3 小时前
鸿蒙PC_Dart-Sass-ohos适配全记录
linux·运维·windows
菜根Sec4 小时前
没钱考网络安全证书了
网络·网络安全·信息安全·信息安全工程师·网络安全公司
充电zcx10 小时前
Linux:4:开发工具详解
linux·运维·服务器