运输层的作用

运输层将网络层在两个端系统之间的交付扩展到了运行在两个端系统上的应用进程之间的交付。

或者说运输层的协议为运行在不同主机上的应用进程提供了逻辑通信的能力。

因特网中的运输层协议有两种:UDP(用户数据报协议) 和 TCP(传输控制协议)

应用进程和运输层之间不会直接打交道,而是通过套接字来进行数据的传输,所以当运输层收到另一个端系统的某个应用进程发来的数据时,会将该数据交给上层对应的套接字,而非应用进程。

多路复用和分解

一台主机上会运行很多的应用进程,每个应用进程又可能会有一个或多个套接字,所以套接字需要有唯一的标识符来让运输层可以实现准确的交付。

多路分解: 运输层将报文段中的数据交付到正确的套接字的工作

多路复用: 运输层从上层不同的套接字收集数据并生成报文段,接着将报文段传递到网络层

实现多路分解和复用的要求:

  1. 套接字有唯一的标识符
  2. 每个报文字段有特殊字段(目的端口号、源端口号)来指示该报文字段所要交付的套接字

端口

端口号是一个 16 bit 的数,大小在 0 ~ 65535 之间。

0 ~ 1023 号端口被称为周知端口号,也就是说它们是给特定应用层协议所使用,例如:HTTP 对应的是 80,FTP 对应的是 21

当我们在开发网络应用程序时必须要为其分配一个端口号(严格来说是分配给该程序所使用的套接字),这是运输层为了实现多路分解和复用所必须的。

简单来说运输层会根据套接字所对应的端口号,以及其他的一些信息来判断该将数据交给哪一个套接字。

UDP的多路分解

一个 UDP 套接字是由一个二元组进行标识,该二元组是:(目的 IP 地址, 目的端口号)

因为传输层是分析接收到的数据报来进行套接字的定位,所以所谓地目的就是本机。

由于 UDP 套接字仅由目的 IP 和目的端口进行标识,所以只要是发送到同一个主机的相同端口号的 UDP 报文,都会被交付到同一个进程(套接字)。

通过简单地写一个 UDP 的例子可以明显的看出来,创建的 UDP 套接字是被复用的,或者严格来说相同端口的请求使用的是同一个 UDP 套接字。

服务器:

1
2
3
4
5
6
7
8
9
10
11
12
server = socket(AF_INET,  SOCK_DGRAM) # UDP 套接字

server.bind(('', 8080))

print('server is listening 8080')

while True:
message, addr = server.recvfrom(2048)
message = message.decode()
print('from client', message)
print('client addr', addr)
server.sendto(message.upper().encode(), addr)

客户端:

1
2
3
4
5
6
7
8
9
const client = createSocket('udp4');

client.on('message', (msg, info) => {
console.log('from server', msg.toString());
console.log('server info', info);
client.close();
});

client.send('hello', 8080);

当我们在创建 UDP 客户端套接字时,往往都是没有分配端口的,这时运输层(操作系统)会自动地为该套接字分配一个端口号,我们也可以手动的 bind 一个端口。

1
client.bind(12345);

TCP的多路分解

TCP 套接字由一个四元组标识:(源 IP, 源端口, 目的 IP, 目的端口)

这意味着只要有一个不同就不会定向到同一个套接字,也可以说 TCP 套接字是一一对应的(一个客户端对应一个服务端)。

和 UDP 更加不同的是 TCP 服务器一开始并不会创建一个 TCP 套接字,因为每一个 TCP 套接字需要依赖四元组,而一开始是没有办法创建的,因为缺少客户端的信息(源 IP 和源端口),而 UDP 只要是请求这个端口的都会走这个套接字。

TCP 服务端处理连接的细节:

  • 当我们创建TCP 服务器时会先创建一个 “欢迎套接字”,让它监听端口
  • 当主机接收到该端口的建立连接的报文时,会定位到该进程(该进程在等待连接)
  • 然后该进程就会创建一个新的 TCP 套接字,运输层则使用四元组标识该套接字
  • 一个套接字可以和一个进程关联,也可以和进程的某个线程相关联
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
server = socket(AF_INET, SOCK_STREAM) # TCP 服务器,欢迎套接字

server.bind(('', 8080))

server.listen()

print('server is listening 8080')

def handleConnection(connectionSocket):
current = threading.current_thread()
handleMsg = 'thread: ' + current.name + ' ' + str(current.native_id)
connectionSocket.send(handleMsg.encode())
connectionSocket.close()

while True:
connectionSocket, addr = server.accept() # 来新的连接,创建一个新的 TCP 套接字
# 让每个连接对应一个线程来处理
threading.Thread(target=handleConnection, args=(connectionSocket,)).start()

通过写一个简单例子可以很明显的看出来,只有当有连接到来时,服务端才会真正创建一个和客户端对应的 TCP 套接字。

通过 Node 来写也可以很清晰的感受到,每当到来一个新连接时创建一个新的 TCP 套接字:

1
2
3
4
5
6
7
server.on('connection', socket => {
server.getConnections((_, count) => console.log('客户端数量', count));
socket.on('data', data => socket.write(data));
socket.on('end', () => console.log('连接已经断开'));
socket.on('error', () => { });
socket.end('已连接');
});