Yiner

Mar 12, 2022

TCP连接&读写

 
在程序角度来说,accept函数表示的是三次握手的过程,如果从accept返回,表示三次握手成功,连接已经建立。
 
 

服务器准备连接

创建套接字

  • domain 就是指 PF_INETPF_INET6 以及 PF_LOCAL 等,表示什么样的套接字。
  • type 可用的值是:
    • SOCK_STREAM: 表示的是字节流,对应 TCP;
    • SOCK_DGRAM: 表示的是数据报,对应 UDP;
    • SOCK_RAW: 表示的是原始套接字。
  • 参数 protocol 原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成。protocol 目前一般写成 0 即可。
 

bind 地址绑定

创建出来的套接字如果需要被别人使用,就需要调用 bind 函数把套接字和套接字地址绑定,就像去电信局登记我们的电话号码一样。
💡
bind 函数的第二个参数是通用地址格式sockaddr * addr。这里有一个地方值得注意,那就是虽然接收的是通用地址格式,实际上传入的参数可能是 IPv4、IPv6 或者本地套接字格式。bind 函数会根据 len 字段判断传入的参数 addr 该怎么解析,len 字段表示的就是传入的地址长度,它是一个可变值。
 
这里其实可以把 bind 函数理解成这样:
不过 BSD 设计套接字的时候大约是 1982 年,那个时候的 C 语言还没有void *的支持,为了解决这个问题,BSD 的设计者们创造性地设计了通用地址格式来作为支持 bind 和 accept 等这些函数的参数。
 
对于使用者来说,每次需要将 IPv4、IPv6 或者本地套接字格式转化为通用套接字格式,就像下面的 IPv4 套接字地址格式的例子一样:
对于实现者来说,可根据该地址结构的前两个字节判断出是哪种地址。为了处理长度可变的结构,需要读取函数里的第三个参数,也就是 len 字段,这样就可以对地址进行解析和判断了。
 

设置地址&端口

设置 bind 的时候,对地址和端口可以有多种处理方式。
 
设置通配地址
💡
我们可以把地址设置成本机的 IP 地址,这相当告诉操作系统内核,仅仅对目标 IP 是本机 IP 地址的 IP 包进行处理。
  • 但是这样写的程序在部署时有一个问题,我们编写应用程序时并不清楚自己的应用程序将会被部署到哪台机器上。这个时候,可以利用通配地址的能力帮助我们解决这个问题。通配地址相当于告诉操作系统内核:“Hi,我可不挑活,只要目标地址是咱们的都可以。”
    • 比如一台机器有两块网卡,IP 地址分别是 202.61.22.55 和 192.168.1.11,那么向这两个 IP 请求的请求包都会被我们编写的应用程序处理。
对于 IPv4 的地址来说,使用INADDR_ANY 来完成通配地址的设置;对于 IPv6 的地址来说,使用 IN6ADDR_ANY 来完成通配地址的设置。
 
系统选择端口
💡
如果把端口设置成 0,就相当于把端口的选择权交给操作系统内核来处理,操作系统内核会根据一定的算法选择一个空闲的端口,完成套接字的绑定。
上述设置一般在客户端使用,在服务器端不常使用。一般来说,服务器端的程序一定要绑定到一个众所周知的端口上。服务器端的 IP 地址和端口数据,相当于打电话拨号时需要知道的对方号码,如果没有电话号码,就没有办法和对方建立连接。
 

代码:初始化套接字

 

listen 开始监听

bind 函数只是让我们的套接字和地址关联,如同登记了电话号码。如果要让别人打通电话,还需要我们把电话设备接入电话线,让服务器真正处于可接听的状态,这个过程需要依赖 listen 函数。
  • 初始化创建的套接字,可以认为是一个"主动"套接字,其目的是之后主动发起请求(通过调用 connect 函数)。
  • 通过 listen 函数,可以将原来的"主动"套接字转换为"被动"套接字,告诉操作系统内核:“我这个套接字是用来等待用户请求的。” 当然,操作系统内核会为此做好接收用户请求的一切准备,比如完成连接队列。
  • 第一个参数 socketfd 为套接字描述符,
  • 第二个参数 backlog,在 Linux 中表示已完成 (ESTABLISHED) 且未 accept 的队列大小,这个参数的大小决定了可以接收的并发数目。这个参数越大,并发数目理论上也会越大。但是参数过大也会占用过多的系统资源,一些系统,比如 Linux 并不允许对这个参数进行改变。
 

accept 建立连接后

当客户端的连接请求到达时,服务器端应答成功,连接建立,这个时候操作系统内核需要把这个事件通知到应用程序,并让应用程序感知到这个连接。
这个过程,就好比电信运营商完成了一次电话连接的建立, 应答方的电话铃声响起,通知有人拨打了号码,这个时候就需要拿起电话筒开始应答。
💡
连接建立之后,你可以把 accept 这个函数看成是操作系统内核和应用程序之间的桥梁。
 
  • 函数的第一个参数 listensockfd 是套接字,可以叫它为 listen 套接字,因为这就是前面通过 bind,listen 一系列操作而得到的套接字。
  • 函数的返回值有两个部分:
    • 第一个部分是函数指针参数被设置: cliaddr 是通过指针方式获取的客户端的地址,addrlen 告诉我们地址的大小,这可以理解成当我们拿起电话机时,看到了来电显示,知道了对方的号码;
    • 另一个部分是函数的返回值,这个返回值是一个全新的描述字,代表了与客户端的连接。
💡
这里一定要注意有两个套接字描述字,第一个是监听套接字描述字 listensockfd,它是作为输入参数存在的;第二个是返回的已连接套接字描述字
 

两个套接字

这里和打电话的情形非常不一样的地方就在于,打电话一旦有一个连接建立,别人是不能再打进来的,只会得到语音播报:“您拨的电话正在通话中。”而网络程序的一个重要特征就是并发处理,不可能一个应用程序运行之后只能服务一个客户。
  • 所以监听套接字一直都存在,它是要为成千上万的客户来服务的,直到这个监听套接字关闭;
  • 而一旦一个客户和服务器连接成功,完成了 TCP 三次握手,操作系统内核就为这个客户生成一个已连接套接字,让应用服务器使用这个已连接套接字和客户进行通信处理。如果应用服务器完成了对这个客户的服务,比如一次网购下单,一次付款成功,那么关闭的就是已连接套接字,这样就完成了 TCP 连接的释放。
  • 请注意,这个时候释放的只是这一个客户连接,其它被服务的客户连接可能还存在。最重要的是,监听套接字一直都处于“监听”状态,等待新的客户请求到达并服务。
💡
服务端accept成功后产生“已连接”套接字用于服务当前客户端,而“监听”套接字一直存在,等待新的客户端请求到达。
 

客户端发起连接

第一步还是和服务端一样,要建立一个套接字,方法和前面是一样的。不一样的是客户端需要调用 connect 向服务端发起请求。
 

connect 建立连接

客户端和服务器端的连接建立,是通过 connect 函数完成的。
  • 函数的第一个参数 sockfd 是连接套接字,通过前面讲述的 socket 函数创建。
  • 第二个、第三个参数 servaddraddrlen 分别代表指向套接字地址结构的指针和该结构的大小。套接字地址结构必须含有服务器的 IP 地址和端口号。
 
  • 客户在调用函数 connect 前不必非得调用 bind 函数,因为如果需要的话,内核会确定源 IP 地址,并按照一定的算法选择一个临时端口作为源端口。(客户端可以bind 指定使用固定端口来连接。 没有bind 则会产生一个随机的端口来完成连接请求。)
 
💡
如果是 TCP 套接字,那么调用 connect 函数将激发 TCP 的三次握手过程,而且仅在连接建立成功或出错时才返回。
其中出错返回可能有以下几种情况:
  1. 三次握手无法建立,客户端发出的 SYN 包没有任何响应,于是返回 TIMEOUT 错误。这种情况比较常见的原因是对应的服务端 IP 写错。
  1. 客户端收到了 RST(复位)回答,这时候客户端会立即返回 CONNECTION REFUSED 错误。这种情况比较常见于客户端发送连接请求时的请求端口写错,因为 RST 是 TCP 在发生错误时发送的一种 TCP 分节。产生 RST 的三个条件是:目的地为某端口的 SYN 到达,然而该端口上没有正在监听的服务器(如前所述);TCP 想取消一个已有连接;TCP 接收到一个根本不存在的连接上的分节。
  1. 客户发出的 SYN 包在网络上引起了"destination unreachable",即目的不可达的错误。这种情况比较常见的原因是客户端和服务器端路由不通。
 

TCP三次握手

这里我们使用的网络编程模型都是阻塞式的。所谓阻塞式,就是调用发起后不会直接返回,由操作系统内核处理之后才会返回。
notion image
  • 服务器端通过 socket,bind 和 listen 完成了被动套接字的准备工作,被动的意思就是等着别人来连接,然后调用 accept,就会阻塞在这里,等待客户端的连接来临;
  • 客户端通过调用 socket 和 connect 函数之后,也会阻塞。
接下来的事情是由操作系统内核完成的,更具体一点的说,是操作系统内核网络协议栈在工作。下面是具体的过程:
  1. 客户端的协议栈向服务器端发送了 SYN 包,并告诉服务器端当前发送序列号 j,客户端进入 SYNC_SENT 状态;
  1. 服务器端的协议栈收到这个包之后,和客户端进行 ACK 应答,应答的值为 j+1,表示对 SYN 包 j 的确认,同时服务器也发送一个 SYN 包,告诉客户端当前我的发送序列号为 k,服务器端进入 SYNC_RCVD 状态;
  1. 客户端协议栈收到 ACK 之后,使得应用程序从 connect 调用返回,表示客户端到服务器端的单向连接建立成功,客户端的状态为 ESTABLISHED,同时客户端协议栈也会对服务器端的 SYN 包进行应答,应答数据为 k+1;
  1. 应答包到达服务器端后,服务器端协议栈使得 accept 阻塞调用返回,这个时候服务器端到客户端的单向连接也建立成功,服务器端也进入 ESTABLISHED 状态。
 
形象一点的比喻是这样的,有 A 和 B 想进行通话:
  • A 先对 B 说:“喂,你在么?我在的,我的口令是 j。”
  • B 收到之后大声回答:“我收到你的口令 j 并准备好了,你准备好了吗?我的口令是 k。”
  • A 收到之后也大声回答:“我收到你的口令 k 并准备好了,我们开始吧。”
💡
可以看到,这样的应答过程总共进行了三次,这就是 TCP 连接建立之所以被叫为“三次握手”的原因了。
 
 

为什么需要三次握手

  1. 通信需要:这个问题的本质是,信道不可靠,但是通信双发需要就某个问题达成一致。而要解决这个问题,无论你在消息中包含什么信息,三次通信是理论上的最小值(客户端的来回和服务端的来回共四次,这是最多四次,客户端的接收和服务端的发送可以合并成一个,就是那个sync k, ack j+1)。所以三次握手不是TCP本身的要求,而是为了满足"在不可靠信道上可靠地传输信息"这一需求所导致的。
  1. 防止失效的连接请求到达服务器,让服务器错误打开连接:客户端发送的连接请求如果在⽹络中滞留,那么就会隔很⻓⼀段时间才能收到服务器端发回的 连接确认。客户端等待⼀个超时重传时间之后,就会重新请求连接。但是这个滞留的连接请求 最后还是会到达服务器,如果不进⾏三次握⼿,那么服务器就会打开两个连接。如果有第三次握⼿,客户端会忽略服务器之后发送的对滞留连接请求的连接确认,不进⾏第三次握⼿,因此就不会再次打开连接。
 
 
 

发送数据

发送数据时常用的有三个函数,分别是 write、send 和 sendmsg。
每个函数都是单独使用的,使用的场景略有不同:
  1. 第一个函数是常见的文件写函数,如果把 socketfd 换成文件描述符,就是普通的文件写入。
  1. 如果想指定选项,发送带外数据,就需要使用第二个带 flag 的函数。所谓带外数据,是一种基于 TCP 协议的紧急数据,用于客户端 - 服务器在特定场景下的紧急处理。
  1. 如果想指定多重缓冲区传输数据,就需要使用第三个函数,以结构体 msghdr 的方式发送数据。
 

套接字和普通文件读写

你看到这里可能会问,既然套接字描述符是一种特殊的描述符,那么在套接字描述符上调用 write 函数,应该和在普通文件描述符上调用 write 函数的行为是一致的,都是通过描述符句柄写入指定的数据。乍一看,两者的表现形式是一样,内在的区别还是很不一样的。
  • 对于普通文件描述符而言,一个文件描述符代表了打开的一个文件句柄,通过调用 write 函数,操作系统内核帮我们不断地往文件系统中写入字节流。注意,写入的字节流大小通常和输入参数 size 的值是相同的,否则表示出错。
  • 对于套接字描述符而言,它代表了一个双向连接,在套接字描述符上调用 write 写入的字节数有可能比请求的数量少,这在普通文件描述符情况下是不正常的。产生这个现象的原因在于操作系统内核为读取和发送数据做了很多我们表面上看不到的工作。
 

发送缓冲区

当 TCP 三次握手成功,TCP 连接成功建立后,操作系统内核会为每一个连接创建配套的基础设施,比如发送缓冲区
发送缓冲区的大小可以通过套接字选项来改变,当我们的应用程序调用 write函数时,实际所做的事情是把数据从应用程序中拷贝到操作系统内核的发送缓冲区中,并不一定是把数据通过套接字写出去。
 
这里有几种情况:
  1. 第一种情况很简单,操作系统内核的发送缓冲区足够大,可以直接容纳这份数据,那么皆大欢喜,我们的程序从 write 调用中退出,返回写入的字节数就是应用程序的数据大小。
  1. 第二种情况是,操作系统内核的发送缓冲区是够大了,不过还有数据没有发送完,或者数据发送完了,但是操作系统内核的发送缓冲区不足以容纳应用程序数据,在这种情况下,操作系统内核并不会返回,也不会报错,而是应用程序被阻塞,也就是说应用程序在 write 函数调用处停留,不直接返回。术语“挂起”也表达了相同的意思,不过“挂起”是从操作系统内核角度来说的。
 

阻塞返回

那么什么时候才会返回呢?实际上,每个操作系统内核的处理是不同的。
💡
大部分 UNIX 系统的做法是一直等到可以把应用程序数据完全放到操作系统内核的发送缓冲区中,再从系统调用中返回。
怎么理解呢?别忘了,我们的操作系统内核是很聪明的,当 TCP 连接建立之后,它就开始运作起来。
  • 你可以把发送缓冲区想象成一条包裹流水线,有个聪明且忙碌的工人不断地从流水线上取出包裹(数据),这个工人会按照 TCP/IP 的语义,将取出的包裹(数据)封装成 TCP 的 MSS 包,以及 IP 的 MTU 包,最后走数据链路层将数据发送出去。
  • 这样我们的发送缓冲区就又空了一部分,于是又可以继续从应用程序搬一部分数据到发送缓冲区里,这样一直进行下去,到某一个时刻,应用程序的数据可以完全放置到发送缓冲区里。
  • 在这个时候,write 阻塞调用返回。注意返回的时刻,应用程序数据并没有全部被发送出去,发送缓冲区里还有部分数据,这部分数据会在稍后由操作系统内核通过网络发送出去。
 
notion image
 

读取数据

read函数

  • read 函数要求操作系统内核从套接字描述字 socketfd读取最多多少个字节(size),并将结果存储到 buffer 中。
  • 返回值告诉我们实际读取的字节数目,也有一些特殊情况,如果返回值为 0,表示 EOF(end-of-file),这在网络中表示对端发送了 FIN 包,要处理断连的情况;如果返回值为 -1,表示出错。当然,如果是非阻塞 I/O,情况会略有不同。
 
注意这里是最多读取 size 个字节。如果我们想让应用程序每次都读到 size 个字节,就需要编写下面的函数,不断地循环读取。
对这个程序稍微解释下:
  • 6-19 行的循环条件表示的是,在没读满 size 个字节之前,一直都要循环下去。
  • 10-11 行表示的是非阻塞 I/O 的情况下,没有数据可以读,需要继续调用 read。
  • 14-15 行表示读到对方发出的 FIN 包,表现形式是 EOF,此时需要关闭套接字。
  • 17-18 行,需要读取的字符数减少,缓存指针往下移动。
  • 20 行是在读取 EOF 跳出循环后,返回实际读取的字符数。
 
 

代码实验

服务器端读取数据程序

对服务器端程序解释如下:
  • 21-35 行先后创建了 socket 套接字,bind 到对应地址和端口,并开始调用 listen 接口监听;
  • 38-42 行循环等待连接,通过 accept 获取实际的连接,并开始读取数据;
  • 8-15 行实际每次读取 1K 数据,之后休眠 1 秒,用来模拟服务器端处理时延。
 
 

客户端发送数据程序

对客户端程序解释如下:
  • 31-37 行先后创建了 socket 套接字,调用 connect 向对应服务器端发起连接请求
  • 43 行在连接建立成功后,调用 send_data 发送数据
  • 6-11 行初始化了一个长度为 MESSAGE_SIZE 的字符串流
  • 16-25 行调用 send 函数将 MESSAGE_SIZE 长度的字符串流发送出去
 

实验一:观察客户端数据发送行为

客户端程序发送了一个很大的字节流,程序运行起来之后,我们会看到服务端不断地在屏幕上打印出读取字节流的过程:
notion image
 
而客户端直到最后所有的字节流发送完毕才打印出下面的一句话,说明在此之前 send 函数一直都是阻塞的,也就是说阻塞式套接字最终发送返回的实际写入字节数和请求字节数是相等的。
 

实验二:服务端处理变慢

如果我们把服务端的休眠时间稍微调大,把客户端发送的字节数从 10240000 调整为 1024000,再次运行刚才的例子,我们会发现,客户端很快打印出一句话:
notion image
但与此同时,服务端读取程序还在屏幕上不断打印读取数据的进度,显示出服务端读取程序还在辛苦地从缓冲区中读取数据。
 
通过这个例子我想再次强调一下:
💡
发送成功仅仅表示的是数据被拷贝到了发送缓冲区中,并不意味着连接对端已经收到所有的数据。至于什么时候发送到对端的接收缓冲区,或者更进一步说,什么时候被对方应用程序缓冲所接收,对我们而言完全都是透明的。(看不见的意思)
 

小结

💡
小结
  • 对于 send 来说,返回成功仅仅表示数据写到发送缓冲区成功,并不表示对端已经成功收到。
  • 对于 read 来说,需要循环读取数据,并且需要考虑 EOF 等异常条件。
 
 

关于缓冲区

为什么需要缓冲区?
内核协议栈不确定用户一次要发多少数据,如果用户来一次就发一次,如果数据很少,那网络I/O就会很频繁,而真正发送出去的数据也不多,所以为了减少网络I/O 使用了缓存的策略。
 
能否无限增大缓冲区来提高应用程序的吞吐量?
无限增大缓冲区肯定不行,write/send函数发送数据只是将数据发送到内核缓冲区,而什么时候发送是由内核决定的。内核缓冲区总是充满数据时会产生粘包问题,同时网络的传输大小MTU也会限制每次发送的大小,最后由于数据堵塞需要消耗大量内存资源,资源使用效率不高。
 

Copyright © 2024 Yiner

logo