ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

【Linux】三十三.《Linux网络编程一文吃透TCP:Socket API、三次挥手四次握手与多线程/线程池演进》

【Linux】三十三.《Linux网络编程一文吃透TCP:Socket API、三次挥手四次握手与多线程/线程池演进》 一.TCP socket API 详解在和上一节内容中我们详细讲了UDP Socket APIt这一节内容我们讲解TCP Socket API,这些函数都在sys/socket.h里面里面补充一个小点Socket套接字):一个概念/对象表示网络通信的一个端点,是一个文件描述符在 Linux 中表现为 int fdSocket API一套编程接口/函数库用于操作 socket,是一组函数socket、bind、listen、connect 等1.插件套接字 socket就是在通信之前得先把网卡文件打开。socket() 就是干这个的——打开一个网络通讯端口成功就返回文件描述符跟 open() 一样后面 read/write 直接往上添加就行收发数据跟读写文件没什么两样失败返回 -1参数方面对于 IPv4family参数指定为 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 #includeiostream #includestring #includecstring #includecerrno #includesys/types.h #includesys/socket.h #includelog.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(_sock0) { 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 TCPprotocol 给 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_inIPv4然后强转过去示意代码还有图如下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_addrIP 为空时绑定 0.0.0.0监听所有网卡非空则转换为整数后填入。bind(...)执行绑定操作成功返回 0失败返回 -1小于 0 时记录错误并退出。3.设置监听状态 listenTCP 是有连接的服务端不能像 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 server4.获取新链接 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只在本文件可见sockaccept() 返回的通信 fdclientip / 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 逻辑并退出时内核会自动向父进程发送 SIGCHLD17号信号。为了解决这个问题我们可以使用 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 发送 SYNclose 发送 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 字节的缓冲区去接且一次就能读全自带消息边界。
返回列表