一.TCP socket API 详解
在和上一节内容中我们详细讲了UDP Socket APIt这一节内容,我们讲解TCP Socket API,这些函数都在sys/socket.h里面里面
补充一个小点:
Socket(套接字):一个概念/对象,表示网络通信的一个端点,是一个文件描述符(在 Linux 中表现为 int fd)
Socket API:一套编程接口/函数库,用于操作 socket,是一组函数(socket、bind、listen、connect 等)
1.插件套接字 socket
就是在通信之前得先把网卡文件打开。
socket() 就是干这个的——打开一个网络通讯端口,成功就返回文件描述符,跟 open() 一样,后面 read/write 直接往上添加就行,收发数据跟读写文件没什么两样;失败返回 -1
参数方面:
- 对于 IPv4,family参数指定为 AF_INET。
- 对于 TCP 协议,type 参数指定为 SOCK_STREAM,表示面向流的传输协议,就是流式传输那个意思。
- protocol 不用管,填 0 完事
补充:Linux 下一切皆文件,socket 也不例外。拿到 fd 后,后续的 bind、connect、send、recv 等操作本质上都是在这个 fd 上做文章。
根据图片我们可以看到,成功则返回打开的文件描述符(指向网卡文件),失败返回-1。
那说白了这个函数的作用就是打开了一个函数,把网卡和文件联系在了一起。
- domain:一个域,标识了这个套接字的通信类型(网络或者本地)。
(我们完成代码只需要关注上面两个类,第一个 AF_UNIX 表示本地通信,而 AF_INET 表示网络通信。
- type:套接字提供服务的类型
- protocol:想使用的协议,默认为 0 即可,因为前面的两个参数决定了,就已经决定了是 TCP 还是 UDP 协议了。
到这里我们就可以联想到系统中的文件操作,以后各种各样的操作都要通过这个文件描述符,因此在服务端类中我们还需要一个成员变量表示文件描述符。
我们下面的内容用的是 SOCK_STREAM
#pragma once #include<iostream> #include<string> #include<cstring> #include<cerrno> #include<sys/types.h> #include<sys/socket.h> #include"log.hpp" class TcpServer{ public: TcpServer(uint16_t port,std::string ip = "") :_sock(-1) ,_port(port) ,_ip(ip) {} void initServer() { _sock = socket(AF_INET,SOCK_STREAM,0); if(_sock<0) { logMessage(FATAL,"%d:%s",errno,strerror(errno)); exit(2); } logMessage(NORMAL,"create socket success, _sock:%d",_sock); } void start() {} ~TcpServer() { if(_sock >= 0) close(_sock); } private: uint16_t _port; std::string _ip; int _sock; };梳理一下我写的内容:
头文件部分
#pragma once 防止头文件被重复包含,没啥好说的
引了一堆标准库和系统头文件,sys/socket.h 是 socket API 的来源,sys/types.h 是基本系统类型
自己写的 log.hpp 是日志模块,后面用来打消息
TcpServer 类
构造函数:
初始化列表把 _sock 置成 -1,端口和 IP 用传进来的参数赋值;
_sock 一开始是无效值,因为还没创建,等 initServer 里才真正创建;
initServer 函数:
调用 socket(AF_INET, SOCK_STREAM, 0) 创建套接字,IPv4 + TCP,protocol 给 0 让系统自己定;
返回值判断:小于 0 说明创建失败,用 logMessage 打一条 FATAL 日志,把 errno 和错误信息串进去,然后 exit(2) 退出;
成功的话打一条 NORMAL 日志,把 _sock 的值打出来——正常情况第一个 socket fd 是 3,因为 0/1/2 已经被 stdin/stdout/stderr 占了。
2.bind
- 服务器程序得有一个固定的地址和端口号,不然客户端上哪儿找你去。所以服务器需要调用bind() 把自己绑定到一个具体的网络地址和端口上。
- bind() 就是把 sockfd 这个文件描述符和 myaddr 里指定的地址/端口绑定到一起,之后这个 socket 就固定监听这个地址了。
- 参数方面:myaddr 是 struct sockaddr* 类型,一个通用指针,能接收各种协议对应的 sockaddr 结构体——IPv4、IPv6、Unix domain socket 啥的都行。正因为结构体类型不同、长度也不一样,所以才需要第三个参数 addrlen 来告诉内核你传的结构体到底有多长。
- bind() 成功返回 0,失败返回 -1。
- socket:创建套接字的返回值。
- address:通用结构体(前一节内容有详细介绍)。
- address_len:传入结构体的长度同上
struct sockaddr
- 一个通用的地址结构体,专门用来存 IP 和端口
- bind()、connect() 这些函数用的都是它
- 实际传参时一般传 struct sockaddr_in(IPv4),然后强转过去
示意代码还有图如下:
struct sockaddr_in { sa_family_t sin_family; // AF_INET in_port_t sin_port; // 端口号,网络字节序 struct in_addr sin_addr; // IP地址 }; struct in_addr { uint32_t s_addr; // 32位IP,网络字节序 };因此我们需要先定义一个address_in 结构体填充数据,再传递进去
接下俩,就是跟 UDP 一样,先初始化结构体,再处理 IP 和端口。要注意 IP 要绑定任意 IP,也就是 INADDR_ANY 具体原因如下:
服务器 bind 的时候,IP 地址通常配成 INADDR_ANY。
意思就是告诉内核:监听本机所有的 IP 地址。不管是 127.0.0.1 还是公网 IP,只要端口对得上,包我都收。如果写死成 127.0.0.1,那就只能收本机回环的包,外面机器发过来的全拒掉;写死成某个公网 IP,万一网卡重启、IP 变了,服务直接挂。所以 INADDR_ANY 是最省事儿的做法——不挑网卡,啥 IP 都能收。
bind() 绑定过程
bind() 就是把 socket 和本地的 IP + 端口绑在一起,告诉内核这个 socket 服务哪个地址。
struct sockaddr_in local; // 准备一个 IPv4 地址结构体 memset(&local, 0, sizeof local); // 全部置零,清掉 sin_zero 和 padding local.sin_family = AF_INET; // 指定 IPv4 local.sin_port = htons(_port); // 端口转网络字节序 local.sin_addr.s_addr = ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str()); if(bind(_sock, (struct sockaddr*)&local, sizeof local) < 0) { logMessage(FATAL, "bind error, %d:%s", errno, strerror(errno)); exit(3); }每行干啥的:
- struct sockaddr_in local:定义一个 IPv4 地址结构体,用来装 IP + 端口
- memset:将结构体整体清零,避免残留脏数据。
- sin_family = AF_INET:指定使用 IPv4 协议族。
- sin_port = htons(_port):将端口号转换为网络字节序后填入。
- sin_addr.s_addr:IP 为空时绑定 0.0.0.0(监听所有网卡),非空则转换为整数后填入。
- bind(...):执行绑定操作,成功返回 0,失败返回 -1,小于 0 时记录错误并退出。
3.设置监听状态 listen
TCP 是有连接的,服务端不能像 UDP 那样直接收数据,得先告诉内核:我要在这个端口上等着别人来连我。
listen() 就是干这个的——把 socket 从 "刚创建啥也没干" 的状态,切换成 "可以接受连接" 的状态,也就是被动监听模式。
举个简单的例子帮助理解:我们买东西如果出现了问题会先去找客服,那如果客服不在,就无法回复我们,所以就规定了客服在工作的时候必须要时刻接收回复消息,那么这个客服所处的状态就叫做监听状态。
int listen(int sockfd, int backlog);socket 创建完、bind 完之后,得把 socket 设置成监听状态,告诉内核:我准备好了,可以接客了。TCP 是面向连接的,客户端连过来得先完成三次握手,握手成功之后客户端就在队列里等着 accept 来取。
backlog 就是指定这个队列的最大长度——最多允许多少个客户端已经完成握手但还没被 accept 取走。如果队列满了,新来的连接请求会被拒绝。一般设成 10 左右就行了,后面讲 TCP 协议的时候会细说这个值怎么调。
listen() 成功返回 0,失败返回 -1。
来看代码:
这里我们发现创建套接字成功
先代码汇总:
我们发现打印的结果,创建套接字成功,套接字对应的文件描述符值是 3,为什么是 3 呢?
因为当前对应的文件描述符返回的套接字本身就是一个文件描述符,0、1、2 被占用,再创建一个文件,对应的就是 3。
前面我们也说过,端口号用来标识该主机上的唯一的网络服务进程,也就是上面的 8080 代表的就是 tcp_server。同时,我们也说过,一个端口号是不能被被重复绑定的
这里简单补充点小问题,就是端口号
只要满足两个条件:
大于1024(不然要root权限)
没被占用
你给 8080、8888、9999 都行,客户端连的时候用同一个端口就行。
我前面还讲到指令 netstat -anup,用来查看 udp server,现在我们试验一下,用命令 netstat -antp 来查看 tcp server:
4.获取新链接 accept
为什么 TCP 必须先建立连接才能发数据?
TCP 是面向连接的协议,核心就是先握手,后通信。
发数据之前得先确认两件事:
对方在不在?——发个包过去,对方得回一声"我在"才能确定网络是通的。
彼此的状态能不能对上?——比如序号、窗口大小这些参数得先商量好,不然数据发过去对方也接不住。
这三轮确认就是三次握手,由内核自动完成。对应用程序来说,listen() 之后调 accept(),就是等着握手完成,然后把已建立的连接拿回来用。
前面初始化完成,现在就是要开始运行服务端。TCP 不能直接发送数据,因为它是面向链接的,所以必须要先建立链接
accept() 的作用就是从已完成握手的队列里把连接取出来,然后给你一个新的 socket fd,专门用来跟这个客户端通信。原来的监听 fd 继续干它自己的活儿——等着新客户端来连。
成功返回一个文件描述符,失败返回 -1。
- sockfd:监听 socket 的文件描述符,就是 listen() 之前绑定的那个
- addr:输出型参数,用来填客户端的 IP 和端口信息,你传一个 struct sockaddr_in 进去,内核帮你填好然后返回给你
- addrlen:输入输出型参数,传进去的时候告诉内核结构体有多长,返回的时候内核告诉你实际填了多长
三次握手完成之后,服务器调 accept() 把连接取出来用。
accept() 是阻塞的:没有客户端连过来的时候,调用会卡在那里不动,直到有连接进来或者出错
addr 是传出参数:内核把客户端的 IP 和端口填到这个结构体里返回给你,不想知道客户端信息的话传 NULL 就行
addrlen 是输入输出型参数:传进去的时候告诉内核你的结构体有多大,防止溢出;返回的时候内核告诉你实际填了多长
成功返回一个新 fd,专门用来和这个客户端通信;失败返回 -1。原来的监听 fd 继续干自己的活,等着收新连接。
我们的服务器程序结构是这样的( TCP 服务器最经典的结构——一连接一处理的模式:):
while (1) { // 1. 客户端地址结构体大小,传给 accept cliaddr_len = sizeof(cliaddr); // 2. 从监听队列里取出一个已完成的连接,返回新的 fd // cliaddr 由内核填上客户端的 IP 和端口 connfd = accept(listenfd, (struct sockaddr*)&cliaddr, &cliaddr_len); // 3. 从 connfd 读客户端发来的数据 // MAXLINE 是缓冲区大小,buf 是存储数据的数组 n = read(connfd, buf, MAXLINE); // 4. 处理数据(用 ... 表示) // 实际代码会解析 buf 里的内容,做业务逻辑 // 5. 关掉这个连接,回到循环开头等下一个客户端 close(connfd); }程是:
accept() 阻塞等着,有客户端连进来就往下走
用 read() 读取客户端发来的数据
处理完数据之后 close() 关掉这个连接
回到循环开头,继续等下一个客户端
每次 accept 返回一个 connfd,专门服务一个客户端,服务完了就关掉,然后服务下一个。后面的 read()、close() 这些还没写,目前只有 accept() 和 while(1) 循环。这里先知道结构就行,后面会慢慢往里填。
sockfd 本来就是一个文件描述符,那么这个返回的文件描述符是什么呢?
举个例子:门口揽客的(sockfd):从头到尾就一个,他的活儿就是站在那儿招呼客人,把客人领进门,然后回到门口继续招呼下一个。
店里的服务员(connfd):每进来一个客人,就分配一个专门的服务员,全程服务这个客人,服务完了就去服务下一个。
这样一来,我们就知道了,成员变量中的 _sock 并不是通信用的套接字,而是获取链接的套接字。为了方便说明,我们可以把前面所有的 _sock 换成 _listensock。
5.获取信息与返回信息(文件操作)
为什么 TCP 通信能用文件 IO 操作?
Linux 下一切皆文件。
socket 也好,普通文件也好,打开之后返回的都是一个文件描述符(fd)。既然是 fd,那就可以用 read() 和 write() 来读写数据——不管是磁盘文件还是网络 socket,对内核来说操作方式是一样的。
TCP 是面向字节流的,跟文件一样,数据就像流水一样源源不断,没有边界。所以用 read() 读 socket 就跟读文件一样,读多少算多少。
区别在于:
读文件:从磁盘读到内存
读 socket:从网卡缓冲区读到内存
但对应用程序来说,接口是一样的——read()、write()、close() 都能直接用。后面封装 IO 函数,目的就是把这些 read/write 操作包一下,加上错误处理和日志,用起来更方便。
IO 的操作可以封装一个函数,方便后续进行多次扩展:
static void service(int sock, const std::string &clientip, const uint16_t &clientport) { char buffer[1024]; // 数据缓冲区 while (true) // 循环读写 { ssize_t s = read(sock, buffer, sizeof(buffer) - 1); // 从 socket 读数据,留 1 字节给 '\0' if (s > 0) // 读到数据 { buffer[s] = 0; // 末尾补 '\0',转成 C 字符串 std::cout << clientip << ": " << clientport << "#" << buffer << std::endl; // 打印 } else if (s == 0) // 对端关闭连接 { logMessage(NORMAL, "%s:%d shutdown, me too!", clientip.c_str(), clientport); break; } else // 读取出错 { logMessage(ERROR, "read socket error, %d:%s", errno, strerror(errno)); break; } write(sock, buffer, strlen(buffer)); // 把数据原样写回客户端(回显) } }简单解析一下:
函数头
- static:只在本文件可见
- sock:accept() 返回的通信 fd
- clientip / clientport:客户端地址信息,从 accept() 的 addr 参数拿到
循环体
- 一个客户端连进来之后,这个循环会一直运行,直到客户端断开或出错
- 每个客户端一个 service() 实例,互不干扰
read 三种情况
| 返回值 | 含义 | 处理 |
|---|---|---|
s > 0 | 读到s个字节 | 打印 + 原样写回 |
s == 0 | 对端关闭连接 | 打日志 +break退出 |
s < 0 | 读取出错 | 打错误日志 +break退出 |
回显逻辑
- 把读到的 buffer 原封不动用 write() 写回客户端
- 客户端发 "hello" --> 服务器收到 "hello" --> 原样写回 --> 客户端收到 "hello"
当 IO 完之后要记得关闭文件描述符 sock,否则会导致可用描述符越来越少。
那我们发现这个代码是放在类外的,为什么呢?
service() 不需要访问 TcpServer 的私有成员,它是纯 IO 读写逻辑,跟类本身解耦放外面代,码更干净,后面想扩展也方便
a.version 1.0(单进程循环版)
我们验证发现可以运行:
我们简单做一个测试,用命令 telnet(远程登陆工具)对服务端进行连接:
我们或看到这个有奇怪的符号,为什么?
单来说:UTF-8 编码的中文被当成其他格式显示(或者字节丢失)了。
具体原因就两点:
编码不匹配:你输入的中文(UTF-8)和服务器程序/终端使用的编码(如 GBK 或 ASCII)对不上,导致汉字被拆解成了奇怪的符号(比如 ♦)。
输入时丢字了:比如输入“我喜欢你”时,因为网络延迟或终端问题,前面的字丢了,只剩下“欢你”,丢的字变成了乱码
退出只需要输入命令 Ctrl+],再输入 quit 即可:
开始测试多个连接的情况
右边这个跟没参与一样,没有反应?就是说,再启动一个客户端,尝试连接服务器,发现第二个客户端不能正确的和服务器进行通信。当前版本只能一次处理一个客户端, 处理完一个才能处理下一个,这很显然是不能够被直接使用的。
为什么会导致上面的结果呢?
之所以导致上面的结果,是因为服务器代码是单进程串行执行的。主流程在 accept 成功拿到一个连接后,就立刻进入了 service 函数,而 service 内部是一个 阻塞式的 while(read) 死循环。
在这个循环中,只要客户端没有断开连接,read 就会一直阻塞等待数据,导致当前进程的执行流死死卡在这个连接上,无法返回主函数去调用下一次 accept。
因此,只要这个连接不断开,服务器就永远无法获取并处理新的链接,只能“一对一”服务。直到当前客户端关闭(或者 read 返回 0),循环跳出,执行流才会回到 accept 准备迎接下一个客户端。
b.version 2.0(多进程:创建子进程版)
因为 fork 后子进程会复制父进程的文件描述符。这里注意子进程并不需要 _listensock 文件描述符,所以最好关闭。
父进程(主进程)怎么办?是等待吗?
如果父进程在 fork 之后选择盲目等待(如阻塞在 wait 或直接不返回循环),会导致程序再次退化到单进程的阻塞状态,无法继续 accept 新的连接。因此,父进程必须通过异步信号机制来回收资源。
当子进程执行完 service 逻辑并退出时,内核会自动向父进程发送 SIGCHLD(17号信号)。为了解决这个问题,我们可以使用 signal 函数(或更健壮的 sigaction)注册信号回调函数。
在回调函数中,需要将 waitpid 的第一个参数设置为 -1(表示等待任意子进程),并且必须配合 while 循环以及 WNOHANG(非阻塞) 标志。这样设计的目的是:如果多个子进程同时退出,信号可能会合并,但通过循环 waitpid(-1, NULL, WNOHANG) 可以保证把所有的“僵尸进程”全部收割干净,同时避免父进程在回收时被卡死。
这样,父进程在执行完信号处理函数后,就能立刻回到主循环中继续阻塞在 accept() 上,从而实现了并发处理多个连接的目的。
1. 子进程能接管父进程留下的通信 fd 吗?
能。因为 fork 等于把父进程的内存直接复制了一份给子进程,包括文件描述符表。所以子进程里的 socket 编号,和父进程指向的是内核里的同一个对象。子进程拿它去通信,完全没问题。
2. 子进程会继承父进程打开的文件吗?
会继承。但注意,这不是重新打开文件,而是“抄了一份副本”。副作用是,底层那个文件的内核引用计数会加 1。只有父子进程都把这个 fd 关了,底层文件才会真正释放。
3. 子进程去服务客户端,需要留着监听 socket 吗?
不需要。子进程就是专门去聊天(处理数据)的,监听(accept)新连接是父进程的活儿。如果子进程不把 listensock 关掉,万一父进程挂了,子进程手里还攥着它,底层监听文件就不会释放,端口就会被一直占着。所以在子进程里,必须马上 close(listensock),专心去处理 servicesock 就行。
如果父进程关闭 servicesock 会不会影响子进程?
第一幕:顶部的监控窗口(一直在刷屏的那个)
红框里能连续看到好几个 ./tcp_server 8080,有主进程也有子进程。这说明刚才有两个 Telnet 连进来了,服务器成功“生”出了子进程去伺候它们,代码没白写。
第二幕:中间的客户端断开两个客户端都输入了 quit,系统提示 Connection closed by foreign host。也就是主动拔了网线,客户端这边彻底结束了。
第三幕:底部服务器的运行日志(这段最关键)
日志里成功打印了两次 link success,对应的 servicesock 都是 4。
这说明:父进程自己把 4 关掉之后,跑回去接着等人,下一个连接进来系统又把 4 分配给他了。但最关键的是,之前 fork 出来的子进程手里还捏着这个 4,所以依然能正常跟你聊天,最后还打印了 shutdown, me too!。这也正好回答了最上面那句“关了会不会影响子进程”——完全不影响,各玩各的。第四幕:最后的监控窗口(清理完毕)
看最后的红框,客户端断开之后,之前那个子进程就消失了,只剩主进程还在那儿等着接客。关键是没有发现变成 Z 状态的僵尸进程。
说明最前面那行 signal(SIGCHLD, SIG_IGN) 起作用了,孩子死了系统直接帮忙收尸,父进程没被卡住。
二.TCP 功能扩展
(1)创建套接字 socket
简单来说,Socket 就像是网络通信两端的“插座”或者“大门口”。你想发信息给另一个程序,就得先把自己的信息塞进这个 Socket 里,它负责帮你把信息原封不动地丢到对面的 Socket 里。既然服务器那边有个 Socket 等着接客,那咱们客户端这边也得造一个,两边得对上才行:
(2)绑定问题
客户端要不要 bind?
客户端需要端口(这是硬性要求),但千万不要手动 bind,让系统自动给就行。因为客户端没固定端口,内核会在 connect 时随机分配一个。如果你手动 bind 了固定端口,同一台机器上开两个客户端就会冲突(端口被占)。
反过来,服务器必须 bind。如果不 bind,内核会随机分端口,服务器每次重启客户端就找不到了;只有 bind 了固定端口,客户端才能稳定连上。
(3)发起链接 connect
- 客户端需要调用 connect() 连接服务器。
- connect 和 bind 的参数形式一致,区别在于 bind 的参数是自己的地址,而 connect 的参数是对方的地址。
connect() 成功返回 0,出错返回 -1,这里的 addr 和 addrlen 填入的是服务端信息。
在UDP 通信中,客户端在 sendto 时会自动绑定 IP 和 port,但是TCP 就是在 connect 的时候进行绑定。因为 connect 是系统调用接口,所以在调用 connect 时会自动的给绑定当前客户端的 ip 和 port,进而可以让我们在后续使用 sockfd 进行通信。
- connect:就是去敲服务器的门,成败在此一举。敲门敲不开(比如服务器没跑起来、IP填错了),它立马翻脸返回 -1 让你报错退出。
- close(sock):聊完天了,该挂电话就挂电话。如果你不主动挂断,这个端口和文件描述符就会被死死占着不放。万一你写了个死循环一直连接,又忘了关,电脑的端口资源最后都会被榨干,导致后面别人想连都连不上。
(4)客户端并行
a. version 2.1(多进程版)
#include <iostream> #include <string> #include <cstring> #include <cstdlib> #include <unistd.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/wait.h> void usage(const char* proc) { std::cout << "Usage: " << proc << " [server_ip] [server_port]" << std::endl; } int main(int argc, char *argv[]) { if (argc != 3) { usage(argv[0]); exit(1); } std::string serverip = argv[1]; uint16_t serverport = atoi(argv[2]); int sock = socket(AF_INET, SOCK_STREAM, 0); if (sock < 0) { std::cerr << "socket error" << std::endl; exit(2); } struct sockaddr_in server; memset(&server, 0, sizeof(server)); server.sin_family = AF_INET; server.sin_port = htons(serverport); server.sin_addr.s_addr = inet_addr(serverip.c_str()); if (connect(sock, (struct sockaddr*)&server, sizeof(server)) < 0) { std::cerr << "connect error" << std::endl; exit(3); } std::cout << "connect success" << std::endl; // 创建子进程,父进程负责发,子进程负责收 pid_t id = fork(); if (id == 0) { // 【子进程】:只负责一直接收服务器回传的消息 char buffer[1024]; while (true) { ssize_t s = recv(sock, buffer, sizeof(buffer) - 1, 0); if (s > 0) { buffer[s] = 0; std::cout << "\nserver 回显# " << buffer << std::endl; } else if (s == 0) { std::cout << "\n服务器断开了连接" << std::endl; break; } else { break; } } close(sock); exit(0); } else if (id > 0) { // 【父进程】:只负责读取键盘输入并发送 while (true) { std::cout << "请输入# "; std::string line; std::getline(std::cin, line); if (line == "quit") { break; } send(sock, line.c_str(), line.size(), 0); } // 父进程退出前等待子进程 waitpid(id, nullptr, 0); close(sock); } else { std::cerr << "fork error" << std::endl; } return 0; }主进程(运行 ./tcpclient 的那个进程):
它执行到 pid_t id = fork(); 时,生出了一个子进程。子进程(id == 0 的部分):
它继承并拿着 sock,进入死循环,只负责 recv 接收消息。父进程(id > 0 的部分):
它拿着同一个 sock,进入死循环,只负责 getline 读键盘并 send 发送消息。
为什么这是一个真多进程?
因为当父进程在 getline 等你打字时,子进程正在后台同时不停地进行 recv。这就实现了“一边能打字发消息,一边能随时收到服务器回复”。这在网络编程里称为“收发分离”,是客户端最标准、最实用的多进程写法。
如果代码里写了 if(fork() > 0) exit(0);(生出孙子,让儿子立刻死掉):
儿子(中间进程):刚生下来,看了一眼你写的代码,立刻执行 exit(0) 领了盒饭。
爷爷(主进程):正在 waitpid 等儿子死。因为儿子死得极快,爷爷瞬间等到了,于是爷爷执行 close(sock) 然后 直接 return 0; 退出整个程序了。
孙子(真正干活的进程):因为亲爹(儿子)死得太早,它变成了“孤儿”,被操作系统(Linux的1号进程)收养了。它原本是拿着 sock 准备去和你聊天的,但是……它彻底和你的键盘、屏幕(也就是终端)断绝关系了!
b. version 3.0(多线程版)
问:多线程还需不需要像进程那样,刻意去关闭某个特定的文件描述符?
答:完全不需要
原因:因为每个进程都有自己独立的文件描述符表(所以子进程不需要的,必须自己关掉)。但是,多线程不一样!同一进程里的所有线程(包括主线程)是共享同一个文件描述符表的。
这就好比大家共用一个口袋,如果你在里面随便关掉了 sock,直接把整个线程组的数据通路给切断了,别人也都没法用了。所以在线程里,千万不能瞎关文件描述符。
来看代码:
当你在终端 2 和 3 分别发送消息后,终端 1 会瞬间打印出两条 link success,并且显示收到消息。由于是多线程,根本不需要 fork 生成新进程,所以你在终端 4 里只会看到同一个 tcp_server PID 在一直运行。对吗看我的图片
C. version 4.0(线程池版)
前面我们写过线程池,具体可以参考:【Linux】三十.线程篇七《手写线程池 和日志+ 策略模式完整实战、线程安全的单例模式、STL+智能指针(万字解析)》-CSDN博客
【小写字符 -> 大写字符】
来看代码修改:
上面的 change 函数的功能是:将小写字符转换成大写字符。
【在线翻译 —— 英译汉服务器】
来看代码:
三.TCP 协议通讯流程
下图是基于 TCP 协议的客户端/服务器程序的一般流程:
1.服务器初始化流程
- 调用 socket,创建套接字文件描述符。
- 调用 bind,将文件描述符与指定 IP + 端口 绑定;若端口已被占用,bind 失败。
- 调用 listen,声明该描述符为服务器监听套接字,为后续 accept 做准备。
- 调用 accept,阻塞等待客户端连接。
2.建立连接:三次握手
客户端
- 调用 socket,创建文件描述符。
- 调用 connect,向服务器发起连接请求。
- connect 发送 SYN 段,并阻塞等待应答(第一次)。
服务器
- 收到客户端 SYN,回复SYN-ACK表示同意建立连接(第二次)。
客户端
- 收到 SYN-ACK 后,connect 返回,并回复 ACK(第三次)。
TCP 是面向连接的协议,通信前必须通过三次握手建立可靠连接。
3.数据传输过程
连接建立后,TCP 提供 全双工 通信:同一连接、同一时刻,双方可同时发送数据(半双工:同一时刻只能一方发)。
- 服务器从 accept 返回后,立即调用 read;无数据则阻塞等待。
- 客户端调用 write 发送请求;服务器收到后 read 返回,处理请求;此时客户端 read 阻塞等待应答。
- 服务器处理完,调用 write 回传结果,再次 read 等待下一条请求。
- 客户端收到应答后 read 返回,继续发送下一条请求,循环通信。
4.断开连接:四次挥手
- 客户端无更多请求,调用 close 关闭连接,发送 FIN(第一次)。
- 服务器收到 FIN,回复 ACK,同时 read 返回 0(第二次)。
- 服务器得知客户端已关闭,也调用 close 关闭连接,发送 FIN(第三次)。
- 客户端收到 FIN,回复 ACK(第四次)。
TCP 断开连接的过程称为四次挥手。
为什么是四次挥手?
TCP 双向独立、各自可靠:一端关闭需要发送 FIN + 收到 ACK才算单向关闭。
- 客户端主动关闭:客户端 FIN --> 服务器 ACK(两次)。
- 服务器被动关闭:服务器 FIN --> 客户端 ACK(两次) 双方各需要一次关闭与确认,合起来共四次挥手。
5.学习重点:应用层与 TCP 协议层的交互
- 应用调用 socket API 时,TCP 协议层做什么: 如 connect 发送 SYN;close 发送 FIN。
- 应用如何感知 TCP 状态变化: 如阻塞函数返回表示收到对应报文;read 返回 0 表示收到对方 FIN(连接关闭)。
四.总结
对比 UDP 服务器,编写 TCP 服务器的代码流程明显更繁琐。最关键的区别在于:UDP 只需要 socket + bind 就能直接收发了(因为无连接,报文自带IP和端口);而 TCP 服务器必须额外增加 listen(监听)和 accept(获取新链接)的操作。
此外,因为 TCP 是面向字节流的协议,我们在 send 和 recv 时不再像 UDP 那样发送“一个个独立的数据报”,而是像操作管道/文件一样,把数据看成连续不断的字节流来进行读取和写入。正因如此,网络编程底层的收发操作,本质上就是文件IO操作。
TCP 和 UDP 核心对比(三大维度)
a. 可靠性:可靠传输 VS 不可靠传输
TCP:自带确认应答、超时重传、流量控制等机制。发出去的数据包,丢了你重发,没收到应答绝对不算完。所以它是可靠的。
UDP:发出去就不管了。不管对方收没收到,不管网络堵不堵,没有确认机制,丢包就真丢了。所以它是不可靠的。
b.连接性:有连接 VS 无连接
TCP:有连接。通信前必须通过“三次握手”建立一条虚拟通道,通信完还要“四次挥手”断开,双方都清楚对方的状态。
UDP:无连接。不需要提前打招呼,直接打包把数据扔给指定的 IP 和端口就行,简单粗暴(有点像写信,不需要提前打电话预约)。
c. 传输形态:字节流 VS 数据报
- TCP:字节流。数据像水管里的水一样没有明显的边界。你发 100 字节,对方可能一次收 50 字节分两次收,或者拼在一起收。边界需要应用层自己去切分(比如加换行符)。
- UDP:数据报。数据像寄快递一样,每次发送必须是一个完整的数据包。你发 100 字节,对方 recvfrom 就必须用 100 字节的缓冲区去接,且一次就能读全,自带消息边界。