Go 中的 UDP 服务器和客户端:上篇
从 Golang 的 net 包到发送 UDP 消息时调用的 Linux 内核方法。
虽然在 Golang 中看到 TCP 服务器的实现很普遍,但在 UDP 中看到相同的实现并不常见。
除了 UDP 和 TCP 之间的许多差异之外,使用 Go 感觉它们非常相似,除了每个协议细节产生的小细节。
如果您觉得一些 Golang UDP 知识很有价值,请确保您坚持到底。
另外,本文还介绍了 TCP 和 UDP 在 Golang 在后台使用的系统调用方面的潜在差异,以及对调用这些系统调用时内核所做的一些分析。
敬请关注!
总览
作为博客文章的目标,最终实现应该看起来像一个“回显通道”,无论客户端向服务器写入什么,服务器都会回显。
.---> HELLO!! -->-.
| |
client -* *--> server --.
^ |
| |
*---<----- HELLO!! ---------<--------*
作为 UDP 协议,它不能保证可靠传递,可能是服务器接收到消息的情况,也可能是客户端从服务器收到回显的情况。
这一流程可能会成功(或不会)完成。
.---> HELLO!! -->-.
| |
client -* *--> server --.
|
|
whoops, lost! -----<--------*
不是面向连接的,客户端不会像 TCP 那样真正“建立连接”;每当消息到达服务器时,它不会“将响应写回连接”,它只会将消息定向到写入它的地址。
考虑到这一点,流程应如下所示:
TIME DESCRIPTION
t0 client and server exist
client server
10.0.0.1 10.0.0.2
t1 client sends a message to the server
client ------------> server
10.0.0.1 msg 10.0.0.2
(from:10.0.0.1)
(to: 10.0.0.2)
t2 server receives the message, then it takes
the address of the sender and then prepares
another message with the same contents and
then writes it back to the client
client <------------ server
10.0.0.1 msg2 10.0.0.2
(from:10.0.0.1)
(to: 10.0.0.2)
t3 client receives the message
client server
10.0.0.1 10.0.0.2
thxx!! :D :D
ps.: ports omitted for brevity
也就是说,让我们看看这个故事是如何在 Go 中展开的。
此外,如果您想真正深入了解,请务必考虑以下书籍:
- 计算机网络:自上而下的方法
- Unix 网络编程,第 1 卷:套接字网络 API(第 3 版)
- Linux 编程接口
第一本书是从网络堆栈(应用层)的非常高级部分开始,然后深入到最底层,在其中解释协议的细节——如果你需要一个优秀的在不深入研究实现细节的情况下复习网络概念,看看这个!
另外两个更多地是关于 Linux 和 Unix 的——如果你更专注于实现的话,这是非常值得的。祝您阅读愉快!
使用 Go 发送 UDP 数据包
立即开始整个实现(充满注释),我们可以开始描述它,逐个理解,直到我们能够理解幕后发生的每一个交互。
// client wraps the whole functionality of a UDP client that sends
// a message and waits for a response coming back from the server
// that it initially targetted.
func client(ctx context.Context, address string, reader io.Reader) (err error) {
// Resolve the UDP address so that we can make use of DialUDP
// with an actual IP and port instead of a name (in case a
// hostname is specified).
raddr, err := net.ResolveUDPAddr("udp", address)
if err != nil {
return
}
// Although we're not in a connection-oriented transport,
// the act of `dialing` is analogous to the act of performing
// a `connect(2)` syscall for a socket of type SOCK_DGRAM:
// - it forces the underlying socket to only read and write
// to and from a specific remote address.
conn, err := net.DialUDP("udp", nil, raddr)
if err != nil {
return
}
// Closes the underlying file descriptor associated with the,
// socket so that it no longer refers to any file.
defer conn.Close()
doneChan := make(chan error, 1)
go func() {
// It is possible that this action blocks, although this
// should only occur in very resource-intensive situations:
// - when you've filled up the socket buffer and the OS
// can't dequeue the queue fast enough.
n, err := io.Copy(conn, reader)
if err != nil {
doneChan <- err
return
}
fmt.Printf("packet-written: bytes=%d\n", n)
buffer := make([]byte, maxBufferSize)
// Set a deadline for the ReadOperation so that we don't
// wait forever for a server that might not respond on
// a resonable amount of time.
deadline := time.Now().Add(*timeout)
err = conn.SetReadDeadline(deadline)
if err != nil {
doneChan <- err
return
}
nRead, addr, err := conn.ReadFrom(buffer)
if err != nil {
doneChan <- err
return
}
fmt.Printf("packet-received: bytes=%d from=%s\n",
nRead, addr.String())
doneChan <- nil
}()
select {
case <-ctx.Done():
fmt.Println("cancelled")
err = ctx.Err()
case err = <-doneChan:
}
return
}
有了客户端代码,我们现在可以描述它,探索它的每一个细微差别。
地址解析
在我们开始创建套接字并将信息发送到服务器之前,首先发生的是将给定名称(如 google.com)转换为一组 IP 地址(如 8.8.8.8)的名称解析)。
我们在代码中执行此操作的方式是调用 net.ResolveUDPAddr
,在 Unix 环境中,它一直通过以下堆栈执行 DNS 解析:
(in a given goroutine ...)
>> 0 0x00000000004e5dc5 in net.(*Resolver).goLookupIPCNAMEOrder
at /usr/local/go/src/net/dnsclient_unix.go:553
>> 1 0x00000000004fbe69 in net.(*Resolver).lookupIP
at /usr/local/go/src/net/lookup_unix.go:101
2 0x0000000000514948 in net.(*Resolver).lookupIP-fm
at /usr/local/go/src/net/lookup.go:207
3 0x000000000050faca in net.glob..func1
at /usr/local/go/src/net/hook.go:19
>> 4 0x000000000051156c in net.(*Resolver).LookupIPAddr.func1
at /usr/local/go/src/net/lookup.go:221
5 0x00000000004d4f7c in internal/singleflight.(*Group).doCall
at /usr/local/go/src/internal/singleflight/singleflight.go:95
6 0x000000000045d9c1 in runtime.goexit
at /usr/local/go/src/runtime/asm_amd64.s:1333
(in another goroutine...)
0 0x0000000000431a74 in runtime.gopark
at /usr/local/go/src/runtime/proc.go:303
1 0x00000000004416dd in runtime.selectgo
at /usr/local/go/src/runtime/select.go:313
2 0x00000000004fa3f6 in net.(*Resolver).LookupIPAddr <<
at /usr/local/go/src/net/lookup.go:227
3 0x00000000004f6ae9 in net.(*Resolver).internetAddrList <<
at /usr/local/go/src/net/ipsock.go:279
4 0x000000000050807d in net.ResolveUDPAddr <<
at /usr/local/go/src/net/udpsock.go:82
5 0x000000000051e63b in main.main
at ./resolve.go:14
6 0x0000000000431695 in runtime.main
at /usr/local/go/src/runtime/proc.go:201
7 0x000000000045d9c1 in runtime.goexit
at /usr/local/go/src/runtime/asm_amd64.s:1333
如果我没记错的话,整个流程是这样的:
- 它检查我们是否提供了 IP 地址或主机名;如果是主机名,则
- 根据系统指定的查找顺序,使用本地解析器查找主机;然后,
- 最终执行一个实际的 DNS 请求,请求该域的记录;然后,
- 如果所有这些都成功,则检索 IP 地址列表,然后根据 RFC 进行排序;这为我们提供了列表中最高优先级的 IP。
按照上面的堆栈跟踪,您应该能够自己看到“魔术”发生的源代码(这是一件有趣的事情!)。
选择 IP 地址后,我们可以继续。
注意:这个过程对于 TCP 没有什么不同。
《计算机网络:自上而下的方法》一书有一个关于 DNS 的精彩部分。我真的建议您仔细阅读以了解更多信息。我还写了一篇关于使用 Go 从头开始解析 A 记录的博客文章:使用 Go 从头开始编写 DNS 消息。
TCP 拨号与 UDP 拨号
对于我们的 UDP 客户端,我们没有使用 TCP 常用的常规 Dial,而是使用了一种不同的方法:DialUDP
。
这样做的原因是我们可以强制传递的地址类型,以及接收一个专门的连接:“具体类型”UDPConn
而不是通用的 Conn
接口。
尽管 Dial
和 DialUDP
听起来可能相同(即使涉及与内核对话时使用的系统调用),但它们在网络堆栈实现方面最终却大不相同。
例如,我们可以检查这两种方法在底层都使用了 connect(2)
:
TCP
// TCP - performs an actual `connect` under the hood,
// trying to establish an actual connection with the
// other side.
net.Dial("tcp", "1.1.1.1:53")
// strace -f -e trace=network ./main
// [pid 4891] socket(
AF_INET,
-----> SOCK_STREAM|SOCK_CLOEXEC|SOCK_NONBLOCK,
IPPROTO_IP) = 3
// [pid 4891] connect(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("1.1.1.1")}, 16) = -1 EINPROGRESS (Operation now in progress)
...
UDP
// UDP - calls `connect` just like TCP, but given that
// the arguments are different (it's not SOCK_STREAM),
// the semantics differ - it constrains the socket
// regarding to whom it might communicate with.
net.Dial("udp", "1.1.1.1:53")
// strace -f -e trace=network ./main
// [pid 5517] socket(
AF_INET,
-----> SOCK_DGRAM|SOCK_CLOEXEC|SOCK_NONBLOCK,
IPPROTO_IP) = 3
// [pid 5517] connect(3, {sa_family=AF_INET, sin_port=htons(53), sin_addr=inet_addr("1.1.1.1")}, 16) = 0
...
虽然它们几乎相同,但从文档中我们可以看到它们在语义上是如何不同的,具体取决于我们配置通过在 connect(2)
之前发生的 socket(2)
调用创建的套接字的方式:
If the socket sockfd is of type SOCK_DGRAM, then addr is the address to which datagrams are sent by default, and the only address from which datagrams are received.
如果套接字 sockfd 的类型为 SOCK_DGRAM,则 addr 是默认发送数据报的地址,也是接收数据报的唯一地址。
If the socket is of type SOCK_STREAM or SOCK_SEQ‐PACKET, this call attempts to make a connection to the socket that is bound to the address specified by addr.
如果套接字是 SOCK_STREAM 或 SOCK_SEQ-PACKET 类型,则此调用尝试与绑定到 addr 指定地址的套接字建立连接。
我们是否能够通过 TCP 传输验证 Dial 方法将执行真正连接到另一端的行为?当然!
./tools/funccount -p $(pidof main) 'tcp_*'
Tracing 316 functions for "tcp_*"... Hit Ctrl-C to end.
^C
FUNC COUNT
tcp_small_queue_check.isra.28 1
tcp_current_mss 1
tcp_schedule_loss_probe 1
tcp_mss_to_mtu 1
tcp_write_queue_purge 1
tcp_write_xmit 1
tcp_select_initial_window 1
tcp_fastopen_defer_connect 1
tcp_mtup_init 1
tcp_v4_connect 1
tcp_v4_init_sock 1
tcp_rearm_rto.part.61 1
tcp_close 1
tcp_connect 1
tcp_send_fin 1
tcp_rearm_rto 1
tcp_tso_segs 1
tcp_event_new_data_sent 1
tcp_check_oom 1
tcp_clear_retrans 1
tcp_init_xmit_timers 1
tcp_init_sock 1
tcp_initialize_rcv_mss 1
tcp_assign_congestion_control 1
tcp_sync_mss 1
tcp_init_tso_segs 1
tcp_stream_memory_free 1
tcp_setsockopt 1
tcp_chrono_stop 2
tcp_rbtree_insert 2
tcp_set_state 2
tcp_established_options 2
tcp_transmit_skb 2
tcp_v4_send_check 2
tcp_rate_skb_sent 2
tcp_options_write 2
tcp_poll 2
tcp_release_cb 4
tcp_v4_md5_lookup 4
tcp_md5_do_lookup 4
但是,在 UDP 的情况下,理论上,它只负责标记套接字以读取和写入指定地址。
通过我们为 TCP 执行的相同过程(进一步查看系统调用接口),我们可以跟踪 DialUDP 和 Dial 使用的底层内核方法,看看它们有何不同:
./tools/funccount -p $(pidof main) 'udp_*'
Tracing 57 functions for "udp_*"... Hit Ctrl-C to end.
^C
FUNC COUNT
udp_v4_rehash 1
udp_poll 1
udp_v4_get_port 1
udp_lib_close 1
udp_lib_lport_inuse 1
udp_init_sock 1
udp_lib_unhash 1
udp_lib_rehash 1
udp_lib_get_port 1
udp_destroy_sock 1
多……少得多。
如果我们更进一步,尝试探索每个调用中发生了什么,我们可以注意到在 TCP 的情况下 connect(2) 是如何最终真正传输数据的(例如,建立执行握手):
PID TID COMM FUNC
6747 6749 main tcp_transmit_skb
tcp_transmit_skb+0x1
tcp_v4_connect+0x3f5
__inet_stream_connect+0x238
inet_stream_connect+0x3b
SYSC_connect+0x9e
sys_connect+0xe
do_syscall_64+0x73
entry_SYSCALL_64_after_hwframe+0x3d
而在 UDP 的情况下,什么都没有传输,只是进行了一些设置:
PID TID COMM FUNC
6815 6817 main ip4_datagram_connect
ip4_datagram_connect+0x1 [kernel]
SYSC_connect+0x9e [kernel]
sys_connect+0xe [kernel]
do_syscall_64+0x73 [kernel]
entry_SYSCALL_64_after_hwframe+0x3d [kernel]
如果您还不确定这两者真的不同(从某种意义上说,TCP 发送数据包来建立连接,而 UDP 没有),我们可以在网络堆栈中设置一些触发器来告诉我们何时有数据包流动:
# By creating a rule that will only match
# packets destined at `1.1.1.1` and that
# match a specific protocol, we're able
# to see what happens at the time that
# `connect(2)` happens with a given protocol
# or another.
iptables \
--table filter \
--insert OUTPUT \
--jump LOG \
--protocol udp \
--destination 1.1.1.1 \
--log-prefix="[UDP] "
iptables \
--table filter \
--insert OUTPUT \
--jump LOG \
--protocol tcp \
--destination 1.1.1.1 \
--log-prefix="[TCP] "
现在,针对 TCP 目标和 DialUDP 目标运行 Dial 并比较差异。
您应该只看到 [TCP] 日志:
[46260.105662] [TCP] IN= OUT=enp0s3 DST=1.1.1.1 SYN URGP=0
[46260.120454] [TCP] IN= OUT=enp0s3 DST=1.1.1.1 ACK URGP=0
[46260.120718] [TCP] IN= OUT=enp0s3 DST=1.1.1.1 ACK FIN URGP=0
[46260.150452] [TCP] IN= OUT=enp0s3 DST=1.1.1.1 ACK URGP=0
如果您不熟悉 dmesg 的内部工作原理,请查看我的另一篇博文 - dmesg under the hood。顺便说一句,《Linux 编程接口》是一本了解 socket 和其他相关主题的好书!
写一个 UDP“连接”
为特定地址正确创建和配置 UDP 套接字后,我们现在可以按时通过“写入”路径 - 当我们实际获取一些数据并写入从 net.DialUDP 接收的 UDPConn 对象时。
只向给定 UDP 服务器发送一点数据的示例程序如下:
// Perform the address resolution and also
// specialize the socket to only be able
// to read and write to and from such
// resolved address.
conn, err := net.Dial("udp", *addr)
if err != nil {
panic(err)
}
defer conn.Close()
// Call the `Write()` method of the implementor
// of the `io.Writer` interface.
n, err = fmt.Fprintf(conn, "something")
鉴于 Dial 返回的 conn 实现了 io.Writer 接口,我们可以使用类似 fmt.Fprintf (将 io.Writer 作为其第一个参数)之类的东西,让它使用我们传递给它的消息调用 Write()。
如果您还不清楚接口和其他 Golang 概念,请务必查看 Kernighan 的书:The Go Programming Language。
是的,来自与 Dennis Ritchie 一起编写 C 编程语言的人。
在底层,UDPConn 实现了 io.Writer 接口中的 Write() 方法,它是 conn 的组合,conn 是一个结构,它实现了关于写入和读取给定文件描述符的最基本方法:
type conn struct {
fd *netFD
}
// Write implements the Conn Write method.
func (c *conn) Write(b []byte) (int, error) {
if !c.ok() {
return 0, syscall.EINVAL
}
n, err := c.fd.Write(b)
if err != nil {
err = &OpError{Op: "write", Net: c.fd.net, Source: c.fd.laddr, Addr: c.fd.raddr, Err: err}
}
return n, err
}
// UDPConn is the implementation of the Conn
// and PacketConn interfaces for UDP network
// connections.
type UDPConn struct {
conn
}
现在,知道 fmt.Fprintf(conn, "something") 最终会以 write(2) 方式写入文件描述符(UDP 套接字),我们可以进一步调查,看看内核路径如何寻找这样的 write(2) 调用:
PID TID COMM FUNC
14502 14502 write.out ip_send_skb
ip_send_skb+0x1
udp_sendmsg+0x3b5
inet_sendmsg+0x2e
sock_sendmsg+0x3e
sock_write_iter+0x8c
new_sync_write+0xe7
__vfs_write+0x29
vfs_write+0xb1
sys_write+0x55
do_syscall_64+0x73
entry_SYSCALL_64_after_hwframe+0x3d
此时,数据包应该正在到达通信通道的另一端。
- 点赞
- 收藏
- 关注作者
评论(0)