在网络中如何标记一个进程?
端口号使用 16 比特表示,取值范围是 0 ~ 65535
,端口号分为以下三类:
熟知端口号或系统端口号,数值为 0 ~ 1023
,IANA 把这些端口号指派给了 TCP/IP 最重要的一些应用程序,让所有的用户都知道。
当一种新的应用程序出现后,IANA 必须为它指派一个熟知端口,否则因特网上的其他应用进程就无法和它进行通信。
登记端口号,数值为 1024 ~ 49151
,这类端口号是为没有熟知端口号的应用程序使用的。
使用这类端口号必须在 IANA 按照规定的手续登记,以防止重复,比如 Oracle 的默认端口号 1521
, MySQL 的默认端口号 3306
, Java WEB 中 tomcat 的默认端口号 8080
客户端使用的端口号,数值为 49152 ~ 65535
。
由于这类端口号仅在客户进程运行时才动态选择,因此又叫做短暂端口号。这类端口号是留给客户进程选择暂时使用的。理论上,不应为服务端分配这些端口,实际上,机器通常从1024
起分配动态端口。
当服务器进程收到客户进程的报文时,就知道了客户进程所使用的端口号,因而可以把数据发送给客户进程。
通信结束后,刚才已使用过的客户端口号就不复存在,这个端口号就可以供其他客户进程重复使用。
每个协议都是为了解决一个具体特定的问题:
UDP 的首部:
TCP 的首部:
TCP 的首部包含源端口、目的端口、序号(seq)、确认号(ack) 等。
UDP 是面向应用报文的,对报文既不合并,也不拆分;TCP 是面向字节流的,这也是 TCP 实现可靠传输、流量控制、拥塞控制的基础。
发送方的 TCP 将应用层交付下来的数据看成是一连串的、无结构的字节流,TCP 将字节先存储在自己的缓存中,根据发送策略,先发送一部分字节;接收方提取字节,并存储在自己的缓存中;接收方的应用进程必须有能力识别收到的字节流,把它还原成有意义的应用数据。
总结:
TCP 可靠传输的实现:
序号(seq
)字段:占 4
个字节
也就是使用 32 个比特来编号,序号大小范围 [0, 2^32 - 1]
,共 2^32
个序号(即 4 294 967 296 个)。
当序号增加到 2^32 - 1
后,下一个序号就又回到 0
。
TCP 是面向字节流的,在一个 TCP 连接中传送的字节流中的每一个字节都按顺序编号。
整个要传送的字节流的起始序号必须在连接建立时设置。
首部中的 seq
字段值则指的是本报文段所发送的数据的第一个字节的序号。
确认号(ack
)字段:占 4
个字节
ACK 字段 (Acknowledgement,确认):
窗口字段:占 2
字节
窗口值是 [0, 2^16 - 1]
之间的整数。
窗口指的是发送本报文段的一方的接收窗口(而不是自己的发送窗口)。
窗口值告诉对方:从本报文段首部中的确认号ack
算起,接收方目前允许对方发送的数据量。之所以要有这个限制,是因为接收方的数据缓存空间是有限的。 (简单理解:我给你发数据,我会在数据中顺便告诉你,我的接收缓存区有多大,你再给我回复数据时,不给我超了范围)
总之,窗口值作为接收方让发送方设置其发送窗口的依据。窗口值是经常在动态变化着。
SYN = 1
,表示这是一个 TCP 连接请求报文
序号字段 seq
被设置为一个初始值 x
,作为 TCP 客户进程所选择的初始序号
TCP 规定:SYN = 1
的报文段不能携带数据,但要消耗掉一个序号
SYN = 1,ACK = 1
,表明这是一个对 TCP 连接请求的确认报文seq
被设置为一个初始值 y
,作为 TCP 服务进程所选择的初始序号(TCP 是全双工通信的,客户端和服务端都可以接发数据的)ack = x + 1
,这是对 TCP 客户进程所选择的初始序号的确认ACK = 1
,表明这是一个普通的确认报文段seq = x + 1
,是在上次选择的初始序号x
的基础上加 1
作为本次的序号值ack = y + 1
,是对服务端初始序号的确认三次握手之后,客户端和服务端的 TCP 连接已建立,就可以进行相互发送数据了:
采用三报文握手,而不是两报文握手,来建立 TCP 连接,是为了防止已失效的连接请求报文段突然又到达了 TCP 服务器,此时服务端又会向客户端发送确认请求报文,而客户端因为是关闭状态无法响应服务端,则服务端会因为不知道客户端已经处于关闭状态,还在一直不停的给客户端发送确认请求报文,因而浪费服务器资源。
第一次握手:客户端发送 SYN = 1 seq = x 给服务端。
SYN = 1 表示这是一个TCP连接请求报文
seq = x 表示客户端进程使用序号 x 作为初始序号
TCP规定:SYN
被设为1
的报文不能携带数据,但要消耗一个序号。
第二次握手:服务端发送 SYN = 1 ACK = 1 seq = y ack = x + 1 给客户端。
SYN = 1 ACK = 1 表示当前报文是服务端对客户端前一次发送的连接请求报文的确认报文
ack = x + 1 就是确认了客户进程上一次的起始序号 x,并且期望客户端下一次发送的报文的起始序号是 x + 1
seq = y 则表示服务端进程选择 y 作为初始序号
TCP 和 UDP 都是全双工通信的,客户端和服务端都可以收发数据。
第三次握手:客户端发送 ACK = 1 seq = x + 1 ack = y + 1 给服务端。
ACK = 1 表当前报文是对上一次的普通确认报文
seq = x + 1 表示当前报文选择使用上次的初始序号x
加1
,也就是服务端期望的序号
ack = y + 1 表示确认服务端上一次报文发来的序号y,并期望服务端下一次发送报文的起始序号是 y + 1。
说人话版本的理解:
SYN = 1
)当前使用起始序号 x
(seq = x
)SYN = 1 ACK = 1
),你下一次应该从 x + 1
序号开始(ack = x + 1
),我请求当前使用起始序号 y
(seq = y
)ACK = 1
),你下一次应该从 y + 1
序号开始 (ack = y + 1
),我当前使用你期望我发送的序号 x + 1
(seq = x + 1
)说骚话版本的理解:
互相伤害版本的理解:
为什么最后需要发送一次普通确认报文 / 为什么TCP需要三次握手而不是两次握手?
为什么?RFC793,也就是TCP的协议,RFC中就谈到了原因,这是因为:
为了实现可靠数据传输, TCP协议的通信双方,都必须维护一个序列号, 以标识发送出去的数据包中,哪些是已经被对方收到的。
三次握手的过程即是通信双方相互告知序列号起始值,并确认对方已经收到了序列号起始值的必经步骤。如果只是两次握手, 至多只有连接发起方的起始序列号能够被确认, 而另一方选择的序列号则得不到确认。 至于为什么不是四次,很明显,三次握手后,通信的双方都已经知道了对方序列号起始值,也确认了对方知道自己序列号起始值,第四次握手已经毫无必要了。
为了防止已失效(如超时重传)的连接请求报文突然又到达了服务器,浪费服务器资源。 如果采用两次握手,则第一次请求之后,服务端就确认已建立连接,那么此时如果客户端关闭连接之后,服务端又收到了客户端之前已失效的 TCP 连接请求后,向客户端发送确认请求报文,此时客户端因为是关闭状态无法响应服务端,则服务端会因为不知道客户端已经处于关闭状态,还在一直不停的给客户端发送确认请求报文,导致浪费资源。
第一次挥手:
FIN = 1
,表示是一个 TCP 释放连接请求报文第二次挥手:
根据 TCP 标准,前面发送过的 FIN 报文段要消耗一个序号,从 A 到 B 这个方向的连接就释放了,这时的 TCP 连接处于半关闭(half-close)状态,即 A 已经没有数据要发送了,但 B 若发送数据,A 仍要接收。也就是说,从 B 到 A 这个方向的连接并未关闭,这个状态可能会持续一些时间。
第三次挥手:
第四次挥手:
如果建立连接后,服务端进程挂了,会给客户端发送一个 FIN 包,客户端回复一个 ACK 包,此时如果客户端继续向服务端写数据,服务端会回复一个 RST 包,然后终止。
如果服务端进程是因为断电挂了,则客户端向服务端继续写数据,会一直等待得不到任何响应,最终等待超时。客户端进程需要设置超时时间。
TCP 服务器进程每收到一次 TCP 客户进程的数据,就重新设置并启动保活计时器(2小时定时)
若保活计时器定时周期内未收到 TCP 客户进程发来的数据,则当保活计时器到时后,TCP服务器进程就向 TCP 客户进程发送一个探测报文段
以后每隔 75
秒钟发送一次,若一连发送了 10
个探测报文段后仍无 TCP 客户进程的响应,TCP 服务器进程就认为 TCP 客户进程所在主机出了故障,接着就关闭这个连接。
说白了,TCP 保活计时器就是一个超时等待的终止机制。
第一次:客户端发送 FIN = 1 ACK = 1 seq = u ack = v 给服务端。FIN = 1 表示这是一个 TCP 释放连接的请求报文
TCP规定:FIN 报文段即使不携带数据,也消耗一个序号
第二次:服务端发送 ACK = 1 seq = v ack = u + 1 给客户端。表示确认了客户端第一次的释放连接请求报文。
此时 A → B 方向的连接释放了,TCP 处于半关闭状态,虽然 B 确认了 A 不会再发送数据了,但 B 仍有可能给 A 发送数据。
第三次:服务端发送 FIN = 1 ACK = 1 seq = w ack = u + 1 给客户端,表示服务端释放连接的请求报文
第四次:客户端发送 ACK = 1 seq = u + 1 ack = w + 1 给服务端。
此时 A 也确认了 B 的关闭状态,B 直接进入关闭状态,A 进入 TIME_WAIT 等待状态,等待时长为 2MSL,即最长报文生命周期的 2 倍,如果这期间没有收到对方的报文,A 才最终进入关闭状态。
一句话总结就是:双方都要发起连接终止请求,并且双方都要确认对方发来的连接终止请求。
客户端和服务端都可以发起 FIN 断开连接请求(TCP 是全双工的),但只有发起连接终止的一方会进入 TIME_WAIT 等待状态
在实际中第二次和第三次会合并为一次报文发送。
四次分手版本的理解:
为什么 TCP 的挥手需要四次?
TCP 是全双工的连接,必须两端同时关闭连接,连接才算真正关闭。
如果 A 已经准备关闭写,但是它还可以读 B 发送来的数据。此时,A 发送 FIN
报文给 B 收到后,B 回复 ACK
报文给 A。
当 B 也准备关闭写,发送 FIN
报文给 A,A 回复 ACK
给 B。此时两端都关闭了,TCP 连接才正常关闭。
所以对全双工模式来说,为了彻底关闭,就需要通信两端的 4 次交互才行互相确认对方的关闭状态。
为什么发起方要等待 2MSL ?/ 为什么要存在 TIME_WAIT 状态
保证可靠的终止 TCP 连接。
如果第四次客户端给服务端发送的确认报文丢失了,则服务端会因为没有收到确认报文超时重传 FIN
报文给客户端。
但是如果此时客户端处于关闭状态,就会无法响应服务端了,那么服务端就会一直在重传 FIN 报文,也就是说服务端一直得不到确认,最终会无法进入 CLOSED 状态。
保证让迟来的 TCP 报文有足够的时间被识别并丢弃。
在 Linux 中,一个 TCP 端口不能被同时打开多次,当一个 TCP 连接处于 TIME_WAIT
状态时,我们无法使用该连接的端口来建立一个新连接。
反过来思考,如果不存在 TIME_WAIT
状态,则应用程序能够立即建立一个和刚关闭的连接相似的连接(这里的相似,是指他们具有相同的 IP 地址和端口号)。这个新的、和原来相似的连接被称为原来连接的化身。新的化身可能收到属于原来连接携带应用程序数据的 TCP 报文段(迟到的报文段),这显然是不该发生的。这就是 TIME_WAIT
状态存在的第二个原因。
如果建立连接后,TCP 某一端进程挂了,会发生什么?
如果是服务端进程挂了,会给客户端发送一个FIN
包,客户端回复一个ACK
包,此时如果客户端继续向服务端写数据,服务端会回复一个RST
包,然后终止。
如果服务端进程是因为断电挂了,则客户端向服务端继续写数据,会一直等待得不到任何响应,最终等待超时。客户端进程需要设置超时时间。
同理,如果是客户端进程挂了,服务端进程继续给客户端发送数据的响应情况跟上面类似,对调了一下。
服务端如何检测客户端挂了:通过 TCP 保活计时器,类似超时等待 + 心跳机制。
TCP 是以流的方式来处理数据,一个完整的包可能会被 TCP 拆分成多个包进行发送,也可能把小的封装成一个大的数据包发送。
粘包就是一次接收到多个消息,应用进程无法从一个粘包中解析出数据。
出现粘包的原因:
① 发送方每次写入数据 < 内核缓冲区大小
② 接收方读取内核缓冲区不够及时
半包就是一个消息分多次接收,应用进程无法从一个半包中解析出数据。
出现半包的原因:
1500
字节),必须拆包粘包和拆包的根本原因:TCP 是面向字节流的,消息无边界,应用进程无法从一个粘包和半包中解析出数据。但这不是 TCP 本身的问题,而是上层应用层需要处理的问题。
应用进程如何解读字节流呢?也就是如何解决粘包和半包问题?
解决粘包和拆包的根本手段:确定消息的边界
即每次发送固定长度字节。 比如每 3
个字节表示一个消息:
这种方案简单,但是缺点是会浪费空间。 比如规定每10
个字节表示一个消息,但是客户端发送的消息只包含1
个字节,那么剩余9
个字节就需要补空或者补0
,浪费了。
比如使用回车符(\n
)作为分隔符:
再例如 HTTP 报文头中就使用了回车符、换行符作为 HTTP 协议的边界:
这种方案简单,空间也不浪费,但是缺点是如果消息内容本身出现分隔符时,需要转义,所以需要提前扫描内容。
即在消息头部附加一个固定长度的字段来存储本次消息内容的长度,例如在每个消息的头部使用固定4
个字节的大小来表示消息内容的长度:
在接收方解析时也是先读取固定长度的字段,获取长度,然后根据长度读取数据内容,精确定位数据内容,不需要转义。
缺点:数据内容长度有限制,需要提前知道最长消息的字节数。
一般这种方案是比较推荐的,但也要看场景,有时第一种和第二种会更简单。
应用进程在调用write()
方法向socket
发送数据时,数据并没有被真正从网络上发送出去,只是从应用程序拷贝到了内核缓冲区中,至于什么时候真正被发送,取决于发送窗口、拥塞窗口以及当前发送缓冲区的大小等条件。
而且,并不是每次调用 write()
发送的数据,都会作为一个整体完整地被发送出去。 数据会分几次分发送出去,也是不确定的。
但是 TCP 接收端的在读取字节时的顺序是保证跟发送时 write 的字节顺序一致的:
TCP 发送一个数据时可能会多次调用write发送小数据包:
那这两个数据包到底什么时候发送到网络上呢?这个取决于接收端的需求。
TCP 在发送字节流时如何决定先发送哪一个字节、后发送哪一个字节呢?
先了解下什么是主机的字节顺序:
可以看到大端序更加符合人类的阅读直觉(从左往右读,从低往高存),但是对计算机来说,小端序往往更加容易处理。
另外,不同的 CPU 上运行不同的操作系统,字节序也是不同的,参见下表。
处理器 | 操作系统 | 字节排序 |
---|---|---|
Alpha | 全部 | Little endian |
HP-PA | NT | Little endian |
HP-PA | UNIX | Big endian |
Intelx86 | 全部 | Little endian <-----x86系统是小端字节序系统 |
Motorola680x() | 全部 | Big endian |
MIPS | NT | Little endian |
MIPS | UNIX | Big endian |
PowerPC | NT | Little endian |
PowerPC | 非NT | Big endian <-----PPC系统是大端字节序系统 |
RS/6000 | UNIX | Big endian |
SPARC | UNIX | Big endian |
IXP1200 ARM 核心 | 全部 | Little endian |
网络协议采用的是大端序发送字节。
如果发送主机是采用的小端序,则发送数据前需要做小端序到大端序的转化,同样,如果接收方主机是采用小端序,读取数据后需要做大端序到小端序的转化。Linux 系统提供了一些函数可以直接调用来方便进行主机字节顺序和网络字节顺序的转换:
当使用这些函数时,我们并不需要关心主机到底是什么样的字节顺序,只要使用函数给定值进行网络字节序和主机字节序的转换就可以了。
如果发送字符的话,就不用考虑字节顺序的转换,因为每个字符只占用 1 个字节而已。