前言
如果你在工作学习中经常见到socket相关的字眼,但总是未曾深究过其本质,相信这篇文章能够给你带来一些帮助。
网络是计算机体系中绕不开的一环,而socket作为计算机网络体系的重要组成部分,也具备着相当重要的地位。但很多时候,虽然在书本或课堂上了解了TCP、IP等基础网络内容,但我们对socket的理解扔是欠缺的:很可能你见过很多socket相关的报错、日志乃至技术文章中所提及到的相关字眼,你可能已经对其形成了模模糊糊的印象,但对于它到底是什么东西、怎么把握和理解,仍然不够清楚。
曾经,笔者也有着相同的疑惑,因此笔者尝试去追寻和解答心中的这些疑惑,于是便找到了《Unix网络编程卷1:套接字联网API》这本书,它最终很好地帮助笔者解决了心中的这些疑惑。
然而,虽然这本书是一本经历时间考验的相当的经典书籍,但在阅读过程中,笔者仍然发现了很多过时、偏离当前时代背景的内容,也有一些和当今大部分读者阅读需求不太相符的内容。
因此,笔者希望基于这阅读完本书的所认为的精华内容,结合个人的知识、经验和背景,尝试帮助具有相似疑问的读者更高效地解决心中的这个疑问,在一定层面上帮助读者理解socket(更深入的理解和掌握则需要超出本文更多的篇幅,但本文会优先覆盖最常见和常用的内容,如果需要更详细解读,可以向我反馈对应主题)。同时必须注意的是,笔者个人的经验可能具备相当的局限性,各位读者可以选择性和批判性地来阅读,如有错漏,可以指出修正。
什么是socket
相信有一定网络基础知识的读者都清楚,当前的计算机网络体系有着非常清晰的分层,从最底下的物理层、数据链路层到上面的TCP/UDP传输层、应用层等等,每一层都有着属于自己的职责和对应功能。
这样直接来看,网络的使用必然是十分复杂的,虽然其本身分层分界非常清晰,对于我们理解网络的层次、结构和数据流转模式有很好的帮助。但当我们想真正使用时,却带来了非常复杂繁琐的构建过程,想象下,如果发个数据,要从HTTP到数据链路封包全部构建一遍,那这样的开发和实现成本必然是相当高的。
于是,在这种背景下,操作系统的构建者为我们提供了很好的支持,他们将底层屏蔽,抽象出了一个统一的网络操作接口,来便于上层用户使用网络,这个统一网络操作接口就是socket体系。
socket的原意是“插槽”,就是用电时的插口,这样每个使用电器的人都不需要了解如何发电、运输电力再去使用电力,而是直接简单并且统一地把插口插入电器插槽,就能完成对复杂电力的使用,这一点和我们在使用socket操作网络时的模式是类似的,这是一个很好的比喻。
如何使用socket
socket是一套抽象接口体系,你可以理解为一套使用协议、或是API。
因为是操作系统层面封装提供的机制体系,因此其具体使用需要以系统调用的形式来实现,常见的socket相关系统调用有:
- socket:创建socket,所有socket操作的第一步,socket接口体系提供了非常多种类的socket,每种都有其使用场景,后面会详细解释
- getsocket、setsocket:获取和设置套接字选项,非常重要的操作,很多和socket的交互细节都通过这个方法来进行控制,在各个场景下都用不同的用法,后面会详细解释
- bind:传输层语义的绑定操作,将某个IP地址和端口组合进行绑定,作为独占资源,其他操作者无法使用这个组合,是一种网络资源声明所有权的操作类型
- listen:传输层语义的监听操作,被动等待某个其他的网络实体向自身(即bind的IP和端口组合)发起通信(通常是connect)
- accept:传输层语义的连接确认操作,确认某个尝试建立连接的网络实体和自身之间的关系,一旦accept,正式的传输层连接就已建立,可以开始后续的数据交互
- connect:传输层语义的连接操作,连接某个四层(传输层)网络实体(也就是协议+IP+端口号)(TCP、UDP或其他四层协议),对TCP来说就是最经典的三次握手过程
- shutdown、close:传输层语义的关闭操作,关闭某个已经建立的连接
- send、sendto等:传输层语义的发送操作,连接建立后,某一端如果要发送数据,可以通过这个操作把要发送的数据发送到对端
- recv、recvfrom等:传输层语义的接收操作,接受对端的数据
我们看到,上述系统调用相当多都是传输层语义的,这说明其实socket很大一部分功能是提供四层、也就是传输层网络之上的接口,但却并非所有操作都是基于四层的,后面我们可以了解到还有其他基于层面的socket。
有哪些种类的socket,都有什么作用
常见的socket类型有:
- TCP(SOCK_STREAM) :最常用的类型,封装以提供TCP层面的网络操作
- UDP(SOCK_DGRAM) :很常用的类型,封装以提供UDP层面的网络操作
- Unix(AF_UNIX) :很常用的类型,常用于本机进程间的直接通信,基本不依赖网络协议栈处理,相对普通本地网络通信,性能更高、通信更可靠
- Raw(SOCK_RAW) :用于直接对IP、ICMP等较低层面的数据包进行访问和操作,用于实现一些IP层数据包访问及处理时会使用,比如ping、traceroute等程序,或是实现一个TCP、UDP之外的传输层协议等等
- Netlink:用于用户空间和内核空间进行通信,比如获取路由、网络配置信息等等内核中保存的信息
- Packet(PF_PACKET) :用于直接对数据链路层的数据包进行访问和操作,值得一提的是,我们常用的抓包软件,如tcpdump等工具,并不是利用这个socket进行工作的,而是使用BPF接口,这是一个独立于packet socket的访问和操作数据链路层数据包的方法
- 其他:还有其他若干种socket,只是相比以上使用场景非常少,这里省略
这里我们其实可以对上面产生的疑问进行解答:可以看到socket的种类非常多,远远不止四层传输层一层,我们还可以做很多其他操作,甚至在网络范畴外的,socket也是逐渐从网络通信层面逐渐发展演变成了一个数据通信操作的标准接口体系的
有哪些常见常用的socket选项
对大部分开发者来说,最重要的socket选项重点是和TCP及UDP相关的,主要有这些:
- SO_KEEPALIVE:启用TCP的保活机制,定期检查连接是否仍然有效,防止因客户端异常断开连接而导致的“僵尸连接”
- TCP_NODELAY:禁用或开启Nagle,对于需要低延迟的应用程序,禁用Nagle算法可以防止数据包的延迟发送,但会增加网络流量,Nagle是一种尽可能快速发出数据包的原则性算法
- SO_RCVBUF、SO_SNDBUF:设置Socket的接收缓冲区和发送缓冲区的大小,调整缓冲区大小可以优化Socket的性能,特别是在高吞吐量的场景下
- SO_LINGER:控制Socket关闭时是否立即发送未发送的数据包,或者等待一段时间,调整这个选项可以减少TIME_WAIT socket的数量
- SO_RCVTIMEO、SO_SNDTIMEO:设置接收和发送操作的超时时间,如果不希望一致阻塞在网络的收发读写操作上
- TCP_CORK:在启用时,TCP数据不会立即发送,直到缓冲区满或者显式调用
send()
函数,可以优化TCP数据传输,避免发送小的数据包,提高网络利用率 - SO_REUSEPORT:允许多个进程或线程绑定到同一端口,可以达成内核层面的负载均衡
我们可以看到上面这些socket选项,很多都是和发送、接收行为细节密切相关的,我们在使用时,很多时候底层的框架和三方库都帮我们做了这些选项的配置,但是当我们的实际意图和默认配置有所冲突时,这时候就需要针对性地进行调整了,这时候搞清楚这些选项的作用和调节效果就非常重要了
除此之外,其他类类型的socket也可以设置很多针对性的选项(比如广播和多播的可设置选项就非常多),这些可以在具体场景中去查阅对照,这里就不详细一一列出了
TCP/UDP Socket操作中常见的异常
有几类TCP/UDP socket操作中非常常见的异常,了解这些异常非常有助于我们排查网络操作中的各类异常问题:
TCP通信时对端服务进程故障
如果我们在使用socket通信时,对端服务进程突然挂了或是本来就不存在,但其宿主机器仍然正常时,我们在接收数据或写入数据时会接收到 EOF
相关字样或是 connection reset by peer
、Broken pipe
(对应Linux EPIPE错误码) 类的报错。
各类编程语言的可能报错如下:
- Java:
java.net.ConnectException: Connection refused
- Golang:
dial tcp [remote address]: connect: connection refused
或read(write) tcp [local address] -> [remote address]: read(write): connection reset by peer
- Python:
socket.error: [Errno 111] Connection refused
- C:
connect
函数返回-1
并设置errno
为ECONNREFUSED
这是因为在这种场景下,对端服务进程挂掉或不存在,但宿主机正常,如果还是接收到了发向不可用服务的数据,这时宿主机就会为不可用服务进程对应的socket连接发送一个RST(reset、重置)数据包来通知对端本地服务已经终止了,继续等待没有意义。这是一种很好的做法,这时候发送端可以快速、直接地接收到报错,进行资源回收,避免无意义的等待。
TCP通信时对端服务宿主机故障
这种场景下,由于对端宿主机已经故障了,所以此时不会有任何响应发回来。发送端会按照socket选项的默认配置进行重试发送,如果超过一定的发送次数和时间,会报错并中断连接。
这里默认的重传次数是15次,对应的时间是9min,中间间隔会依照特定退避算法进行,而不是固定频率。如果在日常所遇到的问题碰到了这样的时间间隔,要足够敏感。如果我们不想等待这么长的时间,想要尽快超时失败,也可以通过调整内核参数
tcp_retries2
来修改这里的行为
这里接收到的报错有两类情况:路由器是否替代应答
如果路由器没有替代应答,本地会直接报 timeout
类的错误;如果路由器替代应答,会发回一个ICMP包,告知 destination is unreachable
,那么我们最终收到的也是 unreachable
类的错误。
这里是宿主机一直故障时情况,如果中途宿主机又恢复了,这时候接收到的报错又不一样了,因为此时恢复后的宿主机已经丢失了原先的连接信息,因此也会直接发送一条reset包,对应报错类似于前一种提到的模式。
UDP通信时对端服务不可用
默认的UDP通信在对端服务故障、不存在或丢包时会无限阻塞住,所以一般的底层库都是会提供默认的超时值设置的,比如DNS解析是2s,如果没有,自己在编程时一定要注意超时控制。
同时需要注意的是,如果UDP发送的数据包目的端口被对端判断为无效(目标机器上没有这个端口上的服务)或是其他不可达的情景,对端通常会回复一条ICMP错误消息。因为UDP本身很多时候是可以不调用connect直接发送数据的(这一点和TCP有本质性差异),所以机器上不会有对应的连接信息,并且UDP sendto判定标准是只要数据从缓冲区发出去了,这个操作就是成功的,它不会去管实际的返回数据包是否有报错信息。
因此,此时如果有ICMP报错消息返回,原来的UDP发送进程很可能是接收不到的(这被称为异步ICMP错误问题),因此如果要避免这类问题,通常需要通过一个守护进程来代为接收ICMP包通知,或是UDP发送数据前都确保调用了 connect
再进行后续操作
其他异常case
如果网络通信链路上有netfilter处理,比如ipvs、iptables或其他防火墙模块等,可能会收到一些 EPERM
或是 operation not permitted
类的报错返回,看到这一类报错时,要注意很可能是本机的netfilter框架drop掉了我们的数据包。
总结
本篇文章,笔者介绍了关于socket这个抽象接口体系中最重要和基础的一些内容,相信应该能够为对socket认识模糊的读者提供一些帮助。
了解这些socket基础内容的一个意义在于,当我们在上层编程中再次遇到了一些底层的socket报错或相关问题时,凭借这些基础认识,我们不至于对问题束手无策,也能更清晰理解到底发生了什么,这对相关问题的解决具有非常重要的意义。
需要注意的是,socket体系内存在着大量的细节和内容,限于篇幅和背景的关系,这里笔者只能介绍最基本的内容,很多特殊场景下的扩展性内容无法覆盖。但通过本文建立起对socket的最基础认知后,我们能够看出,使用socket的模式是很明确的,任何人在掌握了其基础本质后,剩下的就是多多查阅socket相关的各类文档,在实践中达成需求目标即可。