TCP 和 UDP 可以使用同一个端口吗?
# TCP 和 UDP 可以使用同一个端口吗?
关于端口的知识点,还是挺多可以讲的,比如还可以牵扯到这几个问题:
- 多个 TCP 服务进程可以同时绑定同一个端口吗?
- 重启 TCP 服务进程时,为什么会出现
Address in use
的报错信息?又该怎么避免? - 客户端的端口可以重复使用吗?
- 多个客户端可以 bind 同一个端口吗?
- 客户端 TCP 连接
TIME_WAIT
状态过多,会导致端口资源耗尽而无法建立新的连接吗?
所以,这次就统一盘点下这些问题。
# TCP 和 UDP 可以同时绑定相同的端口吗?
# 参考答案
可以的。
回顾一下:
- 在数据链路层中,通过 MAC 地址来寻找局域网中的主机。
- 在网际层中,通过 IP 地址来寻找网络中互连的主机或路由器。
- 在传输层中,需要通过端口进行寻址,来识别同一计算机中同时通信的不同应用程序。
所以,传输层的「端口号」的作用,是为了区分同一个主机上不同应用程序的数据包。而传输层有两个传输协议分别是 TCP 和 UDP,在内核中是两个完全独立的软件模块。
当主机收到数据包后,可以在 IP 包头的「协议号」字段知道该数据包是 TCP/UDP,然后可以根据这个信息确定送给哪个模块(TCP/UDP)处理,送给 TCP/UDP 模块的报文根据「端口号」确定送给哪个应用程序处理。
因此,TCP/UDP 各自的端口号也相互独立,互不影响。
如下图所示:
(TCP 和 UDP 模块)
因此,TCP/UDP 各自的端口号也相互独立,如 TCP 有一个 80
号端口,UDP 也可以有一个 80
号端口,二者并不冲突。
# 实验验证
使用 Python 的一个库 socketserver (opens new window),简单写一个 TCP 和 UDP 服务端的程序,它们都绑定同一个端口号 9999
。
官方 TCP 服务端的实现 Demo:
import socketserver
class MyTCPHandler(socketserver.BaseRequestHandler):
"""
The request handler class for our server.
It is instantiated once per connection to the server, and must
override the handle() method to implement communication to the
client.
"""
def handle(self):
# self.request is the TCP socket connected to the client
self.data = self.request.recv(1024).strip()
print("{} wrote:".format(self.client_address[0]))
print(self.data)
# just send back the same data, but upper-cased
self.request.sendall(self.data.upper())
if __name__ == "__main__":
HOST, PORT = "localhost", 9999
# Create the server, binding to localhost on port 9999
with socketserver.TCPServer((HOST, PORT), MyTCPHandler) as server:
# Activate the server; this will keep running until you
# interrupt the program with Ctrl-C
server.serve_forever()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
官方 UDP 服务端的实现 Demo:
import socketserver
class MyUDPHandler(socketserver.BaseRequestHandler):
"""
This class works similar to the TCP handler class, except that
self.request consists of a pair of data and client socket, and since
there is no connection the client address must be given explicitly
when sending data back via sendto().
"""
def handle(self):
data = self.request[0].strip()
socket = self.request[1]
print("{} wrote:".format(self.client_address[0]))
print(data)
socket.sendto(data.upper(), self.client_address)
if __name__ == "__main__":
HOST, PORT = "localhost", 9999
with socketserver.UDPServer((HOST, PORT), MyUDPHandler) as server:
server.serve_forever()
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
运行这两个程序后,通过 netstat
命令可以看到,TCP 和 UDP 是可以同时绑定同一个端口号的。
[root@study ~]# netstat -anp | grep 9999
tcp 0 0 127.0.0.1:9999 0.0.0.0:* LISTEN 19589/python3
udp 0 0 127.0.0.1:9999 0.0.0.0:* 19669/python3
2
3
# 「监听」还是「绑定」
「监听」这个动作是在 TCP 服务端网络编程中才具有的,而 UDP 服务端网络编程中是没有「监听」这个动作的。
TCP 和 UDP 服务端网络相似的一个地方,就是会调用 bind 绑定端口。
两者的区别如下图,其中:
- TCP 网络编程中,服务端执行
listen()
系统调用就是监听端口的动作。 - UDP 网络编程中,服务端是没有监听这个动作的,只有执行
bind()
系统调用来绑定端口的动作。
(TCP 和 UDP 网络编程的区别)
# 多个 TCP 服务进程可以绑定同一个端口吗?
# 参考答案
如果两个 TCP 服务进程同时绑定的 IP 地址和端口都相同,那么执行 bind()
时候就会出错,错误是 Address already in use
。如果两个 TCP 服务进程绑定的端口都相同,而 IP 地址不同,那么执行 bind()
不会出错。
注意:如果 TCP 服务进程 A 绑定的地址是 0.0.0.0
和端口 9999
,而如果 TCP 服务进程 B 绑定的地址是 192.168.1.100
地址(或者其他地址)和端口 9999
,那么执行 bind()
时候也会出错。
这是因为 0.0.0.0
地址比较特殊,代表任意地址,意味着绑定了 0.0.0.0
地址,相当于把主机上的所有 IP 地址都绑定了。
# 实验验证
以前面的 TCP 服务端程序作为例子,启动两个同时绑定 0.0.0.0
地址和 9999
端口的服务进程,会出现报错:
[root@study ~]# netstat -anp | grep 9999
tcp 0 0 127.0.0.1:9999 0.0.0.0:* LISTEN 3915/python3
[root@study ~]#
[root@study ~]# python3 tcp_server.py
Traceback (most recent call last):
File "tcp_server.py", line 24, in <module>
with socketserver.TCPServer((HOST, PORT), MyTCPHandler) as server:
File "/usr/lib64/python3.6/socketserver.py", line 456, in __init__
self.server_bind()
File "/usr/lib64/python3.6/socketserver.py", line 470, in server_bind
self.socket.bind(self.server_address)
OSError: [Errno 98] Address already in use
[root@study ~]#
2
3
4
5
6
7
8
9
10
11
12
13
小贴士
如果想多个进程绑定相同的 IP 地址和端口,也是有办法的,就是对 socket 设置 SO_REUSEPORT
属性(内核 3.9 版本提供的新特性),本文不做具体介绍。
# 如何避免重启报错
重启 TCP 服务进程时,为什么会有 Address in use
的报错信息?
这是在实际开发过程中经常会碰到的一个问题,当 TCP 服务进程重启之后,总是碰到 Address in use
的报错信息,TCP 服务进程不能很快地重启,而是要过一会才能重启成功。
原因就是当我们重启 TCP 服务进程的时候,意味着通过服务器端发起了关闭连接操作,于是就会经过四次挥手,而对于主动关闭方,会在 TIME_WAIT
这个状态里停留一段时间,这个时间大约为 2MSL(详情见TCP 的四次挥手)。TIME_WAIT
这段时间里连接使用的 IP + PORT 仍然被认为是一个有效的 IP + PORT 组合,相同机器上不能够在该 IP + PORT 组合上进行绑定,那么执行 bind()
函数的时候,就会返回了 Address already in use
的错误。
小贴士
重启 TCP 服务进程时如果想要秒启动成功,可以在调用 bind 前,对 socket 设置 SO_REUSEADDR
属性,从而解决这个问题。
其原理就是如果当前启动进程绑定的 IP + PORT 与处于 TIME_WAIT
状态的连接占用的 IP + PORT 存在冲突,但是新启动的进程使用了 SO_REUSEADDR
选项,那么该进程就可以绑定成功。
这个方法还可以用来解决绑定了 0.0.0.0:9999
,就不能绑定 192.168.1.100:9999
的问题。
# 客户端的端口可以重复使用吗?
# 参考答案
在客户端执行 connect
函数的时候,只要客户端连接的服务器不是同一个,内核允许端口重复使用。
TCP 连接是由四元组(源IP地址,源端口,目的IP地址,目的端口)唯一确认的,那么只要四元组中其中一个元素发生了变化,那么就表示不同的 TCP 连接的。
所以,如果客户端已使用端口 64992
与服务端 A 建立了连接,那么客户端要与服务端 B 建立连接,还是可以使用端口 64992
的,因为内核是通过四元祖信息来定位一个 TCP 连接的,并不会因为客户端的端口号相同,而导致连接冲突的问题。
# 理论验证
客户端在执行 connect
函数的时候,会在内核里随机选择一个端口,然后向服务端发起 SYN 报文,然后与服务端进行三次握手。
(客户端执行 TCP 连接请求后的数据流通过程)
所以,客户端的端口选择发生在 connect
函数,内核在选择端口的时候,会从 net.ipv4.ip_local_port_range
这个内核参数指定的范围来选取一个端口作为客户端端口。
该参数的默认值是 32768~61000
,意味着端口总可用的数量是 61000 - 32768 = 28232 个。
当客户端与服务端完成 TCP 连接建立后,我们可以通过 netstat -anpt
命令查看 TCP 连接。
那问题来了,如果客户端已经用了某个端口,那么还可以继续使用该端口发起连接吗?
如果说不可以继续使用该端口了,按这个理解的话,默认情况下客户端可以选择的端口是 28232 个,那么意味着客户端只能最多建立 28232 个 TCP 连接,如果真是这样的话,那么这个客户端并发连接也太少了,显然这是错误理解。
正确的理解是:TCP 连接是由四元组(源IP地址,源端口,目的IP地址,目的端口)唯一确认的,那么只要四元组中其中一个元素发生了变化,那么就表示不同的 TCP 连接的。所以如果客户端已使用某个端口与服务端 A 建立了连接,那么客户端要与服务端 B 建立连接,还是可以使用该端口的,因为内核是通过四元祖信息来定位一个 TCP 连接的,并不会因为客户端的端口号相同,而导致连接冲突的问题。
比如下面,使用 nc
命令建立 2 个 TCP 连接,指定这两个连接都是从本地 50001
端口发起的:
# 先开一个 shell 命令窗口
[root@study ~]# nc -p 50001 183.232.231.174 80
# 再开一个 shell 命令窗口
[root@study ~]# nc -p 50001 39.156.66.18 80
2
3
4
5
使用命令查看 TCP 连接,查询结果中,左边是客户端,右边是服务端,客户端使用了相同的端口 50001
与两个服务端建立了 TCP 连接。而这两条 TCP 连接的四元组信息中的「目的 IP 地址」是不同的,一个是 39.156.66.18
,另外一个是 183.232.231.174
。
[root@study ~]# netstat -npt | grep 50001
tcp 0 0 10.0.16.7:50001 39.156.66.18:80 ESTABLISHED 30412/nc
tcp 0 0 10.0.16.7:50001 183.232.231.174:80 ESTABLISHED 30406/nc
2
3
# 多个客户端可以 bind 同一个端口吗?
# 参考答案
要看多个客户端绑定的 IP + PORT 是否都相同:
- 如果多个客户端同时绑定的 IP 地址和端口都是相同的,那么执行
bind()
时候就会出错,错误是Address already in use
。 - 如果绑定的 IP 不相同,那么执行
bind()
的时候,能正常绑定。
一般而言,客户端不建议使用 bind 函数,应该交由 connect 函数来选择端口会比较好,因为客户端的端口通常都没什么意义。
# 理论验证
bind
函数虽然常用于服务端网络编程中,但是它也是用于客户端的。
前面我们知道,客户端是在调用 connect
函数的时候,由内核随机选取一个端口作为连接的端口。
而如果我们想自己指定连接的端口,就可以用 bind
函数来实现:客户端先通过 bind
函数绑定一个端口,然后调用 connect
函数就会跳过端口选择的过程了,转而使用 bind
时确定的端口。
# 客户端 TCP 连接 TIME_WAIT
状态过多,会导致端口资源耗尽而无法建立新的连接吗?
# 参考答案
针对这个问题要看,客户端是否都是与同一个服务器(目标地址和目标端口一样)建立连接。
如果客户端都是与同一个服务器(目标地址和目标端口一样)建立连接,那么如果客户端 TIME_WAIT
状态的连接过多,当端口资源被耗尽,就无法与这个服务器再建立连接了。
但是,因为只要客户端连接的服务器不同,端口资源可以重复使用的。
所以,如果客户端都是与不同的服务器建立连接,即使客户端端口资源只有几万个, 客户端发起百万级连接也是没问题的(当然这个过程还会受限于其他资源,比如文件描述符、内存、CPU 等)。
# 如何解决客户端 TCP 连接 TIME_WAIT
过多,导致无法与同一个服务器建立连接的问题?
# 参考答案
前面提到,如果客户端都是与同一个服务器(目标地址和目标端口一样)建立连接,那么如果客户端 TIME_WAIT
状态的连接过多,当端口资源被耗尽,就无法与这个服务器再建立连接了。
针对这个问题,也是有解决办法的,那就是打开 net.ipv4.tcp_tw_reuse
这个内核参数。
因为开启了这个内核参数后,客户端调用 connect 函数时,如果选择到的端口,已经被相同四元组的连接占用的时候,就会判断该连接是否处于 TIME_WAIT
状态,如果该连接处于 TIME_WAIT
状态并且 TIME_WAIT
状态持续的时间超过了 1 秒,那么就会重用这个连接,然后就可以正常使用该端口了。
# 实验验证
举个例子,假设客户端已经与服务器建立了一个 TCP 连接,并且这个状态处于 TIME_WAIT
状态:
客户端地址:端口 服务端地址:端口 TCP 连接状态
192.168.1.100:2222 172.19.11.21:9999 TIME_WAIT
2
然后客户端又与该服务器(172.19.11.21:9999)发起了连接,在调用 connect
函数时,内核刚好选择了 2222 端口,接着发现已经被相同四元组的连接占用了:
- 如果没有开启
net.ipv4.tcp_tw_reuse
内核参数,那么内核就会选择下一个端口,然后继续判断,直到找到一个没有被相同四元组的连接使用的端口, 如果端口资源耗尽还是没找到,那么 connect 函数就会返回错误。 - 如果开启了
net.ipv4.tcp_tw_reuse
内核参数,就会判断该四元组的连接状态是否处于TIME_WAIT
状态,如果连接处于TIME_WAIT
状态并且该状态持续的时间超过了 1 秒,那么就会重用该连接,于是就可以使用 2222 端口了,这时 connect 就会返回成功。
再次提醒一次,开启了 net.ipv4.tcp_tw_reuse
内核参数,是客户端(连接发起方)在调用 connect()
函数时才起作用,所以在服务端开启这个参数是没有效果的。
# 客户端端口选择的流程总结
客户端在执行 connect 函数时,内核选择端口的过程如下图所示:
(客户端端口的选择过程)
(完)