在软件中最普遍和生命力最强的接口之一就是是Socket API。Socket API最早是由由加州大学伯克利分校计算机系统研究小组开发的,在1982年作为 BSD 4.1c操作系统的一部分首次发布。虽然有一些使用时间更长的 API ,例如那些处理 Unix 文件 I/O 的 API ,但是一个 API 能够保持使用并且近40年来基本上没有变化,这是及其令人印象深刻的事情了。对Socket API 的主要更新是扩展了辅助程序,以适应 IPv6的大地址空间。
互联网和整个网络世界自 socket API 诞生以来已经发生了非常重大的变化, API 已经改变了开发者思考和编写网络应用程序的方式,但是,网络世界和各种服务的不断变化,给socket API 带来了哪些挑战呢?
Socket 的历史
1982年到如今,关于网络的两个最大区别是拓扑和速度。人们注意到的是速度的提高,而不是拓扑结构的变化。在1982年,商用长途网络链路的最大带宽是1.5 Mbps。而所部署的以太局域网速度为10mbps。局域网上两台计算机之间的往返时间以几十毫秒计算,互联网上各系统之间的往返时间以几百毫秒计算,这当然取决于位置和一个数据包在计算机之间传送时的跳数。一个家庭用户能够通过电话线连接到任何计算设备都是幸福的事,1995年,自己在当时的电报局申请了BTA的邮箱,并兴奋了很久。
当时的网络拓扑结构相对简单,大多数计算机只连接到一个局域网; 局域网连接到一个原始路由器,这个路由器可能有一些到其他局域网的连接或者到互联网的一个连接。对于一个应用程序到另一个应用程序,连接要么跨越局域网,要么传输一个或多个路由器。
分布式编程的模型中最普及的是基于socket API 的客户端/服务器模型,其中有一个服务器和一组客户端。客户端向服务器发送消息,要求服务器代表它们完成工作,等待服务器完成请求的工作,然后在稍后的某个时刻收到答复。这种计算模型已经无处不在,它通常是许多软件工程师所熟悉的唯一模型。然而,在设计socket的时候,它被看作是在计算机网络上扩展 Unix 文件 I/O 模型的一种方法。另一个原因是它支持最流行的 TCP协议,本质上具有点对点的通信模型。
Socket API 使客户机/服务器模型易于实现,程序员只需要将少量的系统调用添加到非联网代码中,就可以利用其他计算资源,这使得Socket API的客户机/服务器模式已经成为主导网络计算的模式。
Socket 中的以下五个函数是 API 的核心,并且是与常规文件 I/O 的区别所在:
socket() | 创建通信端点 |
---|---|
bind() | 将端点绑定到一组网络层参数 |
connect() | 连接服务器提交请求 |
listen() | 监听链路并设置请求的数量限制 |
accept() | 接受来自客户端的一个或多个请求 |
实际上,socket ()调用可以替换为 open ()的一个变体,但是当时还没有这样做。Socket ()和 open ()实际上都是将相同的东西返回给程序: 一个进程唯一的文件描述符,并用于用于该 API 的所有后续操作。socket API 的简单性导致了它的无处不在,但无处不在阻碍了替代或增强API 的开发,而那些 API 可以帮助程序员开发其他类型的分布式应用程序。
socket 面临的挑战
客户机/服务器的计算模式在开发时具有许多优点。它允许许多用户共享资源,有了这种共享模式,就有可能提高资源的利用率。
然而,Socket AP在以下三个不同的网络区域表现不佳:
- 低延迟或实时应用程序
- 高带宽应用程序
- 多宿主系统(即具有多个网络接口的系统)。
许多人混淆了增加网络带宽和提高性能,因为增加带宽并不一定会减少延迟。Socket API 面临的主要是性能挑战,即如何让应用程序更快地访问网络数据。
任何使用socket API 的程序发送和接收数据的方式都是通过对操作系统的调用。所有这些调用都有一个共同点: 调用程序必须不断地请求要传递的数据,因为服务器不能在没有客户机请求的情况下做任何事情。然而,如果服务是音乐或视频呢,那该怎么办?在媒体分发服务中,可能有一个或多个数据源和多个监听器。只要用户在收听或查看媒体,最有可能的情况是应用程序需要任何已经到达的数据。不断地请求新数据是对应用程序的时间和资源的浪费。Socket API 没有向程序员提供这样一种方式: “无论何时有数据需要处理,都直接调用socket来处理它。”
Socket 程序是从数据缺乏而不是数据丰富的角度编写的。网络程序非常习惯于等待数据,因此使用一个单独的系统调用例如 select () ,这样就可以侦听多个数据源,而不会阻塞单个请求。基于 socket 的程序的典型处理循环不是简单地 read ()、 process ()、 read () ,而是 select ()、 read ()、 process ()、 select ()。虽然将单个系统调用添加到循环中似乎不会增加太多负担,但情况并非如此。每个系统调用都需要将参数封送并复制到内核中,同时导致系统阻塞调用进程并调度另一个进程。如果调用者在调用 select ()时可以获得数据,那么跨越用户/内核边界的所有工作都将被浪费,因为 read ()会立即返回数据。除非连续请求之间的间隔时间相当长,否则常规的检查/读取/检查是一种浪费。
要克服这个问题,需要反转应用程序和操作系统之间的通信模型,提供一个允许内核直接调用程序的 API 。但各种尝试中没有一个获得广泛接受。在开发sockket API 时存在的操作系统,在一般情况下,都是在单处理器计算机上执行单线程的。如果内核反调 API,就会有调用在哪个上下文中执行的问题。这种软件架构唯一流行的地方是没有用户和虚拟内存的嵌入式系统和网络路由器。
虚拟内存的问题使得实现内核上行调用机制的问题更加复杂。分配给用户进程的内存是虚拟内存,但网络接口等设备使用的内存是物理内存。让内核将物理内存从设备映射到用户空间,打破了虚拟内存系统提供的基本保护。
面对挑战的尝试与猜想
为了克服socket API 中存在的性能问题,有几种不同的机制,有时在不同的操作系统上实现了这些机制。
低延迟的网络应用
对于那些更关心延迟的程序而言,所做的工作很少。对于正在等待网络事件的程序来说,唯一重要的改进是添加了一组程序可以等待的内核事件,实现异步通知机制。例如 kevents () 是 select ()机制的扩展,它包含了内核可能告诉程序的任何可能的事件。在 kevents ()出现之前,用户程序可以在任何文件描述符上调用 select () ,这样程序就可以知道一组文件描述符中的任何一个是可读的、可写的,或者有错误。当程序被写入一个循环并等待一组文件描述符时,例如从网络读取并写入磁盘ー select ()调用就足够了,但是一旦程序想检查其他事件,例如计时器和信号,select ()就无能为力了。低延迟应用程序的问题在于 kevents ()不传递数据,只传递数据就绪的信号。下一个逻辑步骤是使用基于事件的 API 来传递数据。为了获得内核知道应用程序需要的数据,让应用程序两次跨越用户/内核边界是没有道理的。
高带宽的网络应用
因为复制数据会降低网络协议的性能,其中一种机制是零拷贝socket,为了提高对高带宽更感兴趣的网络应用程序速度,对操作系统进行了修改,以避免更多的数据副本。
传统上,操作系统对系统接收到的每个数据包执行两个副本。第一个拷贝由网络驱动程序从网络设备的内存中执行到内核的内存中,第二个拷贝由内核中的socket层在用户程序读取数据时执行。系统接收到的每个消息都要执行拷贝,导致这些复制操作的成本都较高。同理,当程序想要发送一条消息时,必须将发送的每条消息的数据从用户程序复制到内核; 然后再被复制到设备用来在网络上传输的缓冲区中。
数据复制对系统性能是一种诅咒,可以努力在内核中最小化这种复制。内核避免数据拷贝的最简单方法是让设备驱动程序将数据直接复制到内核内存中或从内核内存中复制出来。在现代网络的设备上,这是如何构建内存的结果。驱动程序和内核共享两个分组描述符环(一个用于发送,一个用于接收) ,其中每个描述符都有一个指向内存的指针。网络设备驱动程序最初用内核的内存填充这些发送/接收环。当接收到数据时,设备在正确的接收描述符中设置一个标志,通常通过中断告诉内核有数据等待。然后,内核从接收描述符环中删除已填充的缓冲区,并将其替换为新的缓冲区,以便设备填充。数据包以缓冲区的形式在网络堆栈中移动,直到到达套接字层,当用户的程序调用 read ()时,数据包从内核中复制出来。程序发送的数据由内核以类似的方式处理,内核缓冲区最终被添加到传输描述符环中,然后设置一个标志来告诉设备它可以将数据放在网络上的缓冲区中。
内核中的所有这些工作都没有解决最后那个拷贝的问题,仍然是跨用户/内核边界安全地共享内存。内核无法将其内存提供给用户程序,因为这时它将失去对内存的控制。崩溃的用户程序可能会使内核失去大量可用内存,从而导致系统性能下降。跨内核/用户边界共享内存缓冲区也存在固有的安全问题。此时,对于如何使用 sockets API 实现更高的带宽,暂时没有单一的答案。
多宿主的网络应用
socket API 不仅在应用程序编写上存在性能问题,而且还减少了可能发生的通信类型。客户机/服务器模式本质上是点对点的通信类型。虽然服务器可以处理来自不同客户机组的请求,但是每个客户机对于一个请求或一组请求只有一个到单个服务器的连接。在一个每台计算机只有一个网络接口的世界里,这种模式非常合理。客户机和服务器之间的连接由 < 源 IP,源端口,目标 IP,目标端口 >
来标识。由于服务通常有一个众所周知的目标端口(例如,HTTP 的目标端口为80) , IP 地址是固定的,所以唯一可以容易改变的值是源端口。
在socket API诞生的年代,每台不是路由器的计算机只有一个网络接口,这意味着为了识别一个服务,客户端计算机需要一个目的地址和端口,而它本身只有一个源地址和端口。一台计算机用多种方式获得服务的想法过于复杂,而且实现起来成本太高。考虑到这些限制,sockets API 没有理由向程序员展示编写多宿主程序的能力,这样的呈现可以管理对其重要的接口或连接。这些特性在实现时是操作系统中路由软件的一部分。程序最终能够访问它们的唯一途径是通过一组名为路由套接字(routing socket)的非标准内核 API。
在具有多个网络接口的系统上,使用标准的Socket API 编写一个可以轻松地多网址址的应用程序是不可能的。如果那样的话,在利用这两个接口时,如果其中一个出现故障,或者如果数据包流经的主要路由出现故障,应用程序不会失去与服务器的连接。
尽管SCTP 在协议级别集成了对多宿主的支持,但是不可能通过socket API 导出这种支持。最初提供了几个临时系统调用,这是访问这一功能的唯一方法。到目前为止,这可能是唯一一个同时具有这个特性的能力和用户需求的协议,但这个 API 还没有在多个操作系统中标准化。下表列出了 SCTP 添加的API:
sctp_bindx() | 将 SCTP socket绑定或取消绑定到地址列表 |
---|---|
sctp_connectx() | 使用多个目标地址连接 SCTP socket |
sctp_generic_recvmsg() | 从对等点接收数据 |
sctp_generic_sendmsg() | 将数据发往对等点 |
sctp_getaddrlen() | 返回地址族的地址长度 |
sctp_getassocid() | 返回指定socket地址的关联 ID |
ctp_getpaddrs()< | 将地址列表返回给调用者 |
sctp_peeloff() | 将关联从一对多套接字分离到单独的文件描述符 |
ctp_getpaddrs() | 将地址列表返回给调用者 |
sctp_sendx() | 从 SCTP 套接字发送消息 |
sctp_sendmsgx() | 从 SCTP 套接字发送消息 |
虽然这个函数列表超过了API必需的数量,但需要注意的是,许多函数都是socket api 的衍生品,例如 send () ,需要扩展才能在一个多宿主的世界中工作。现在的问题是Socket API无处不在,以至于很难改变现有的 API 集合,害怕混淆用户或者已有的应用程序。
随着系统内置了越来越多的网络接口,编写利用多宿主应用程序的能力将是必要的。很容易地想象这种技术在智能手机中的应用,智能手机有三个显然的网络接口: 通过蜂窝网络的接口,WiFi 接口,通常还有一个蓝牙接口。如果哪怕只有一个网络接口正常工作,应用程序也不应该失去连接性。应用程序设计者的问题在于,希望自己的代码能够在很少或没有任何变化的情况下,通过大量的设备工作,从手机到笔记本电脑,再到台式机等等。有了正确定义的API,就可以移除阻止这种情况发生。只是由于 socket API “足够好”的事实,这种需求尚未得到满足。
小结
对高带宽、低延迟和多宿主的支持是socket API 需要面对的挑战。局域网现在已经达到10 Gbps,对于许多应用程序来说,客户机/服务器风格的通信效率太低,可能无法高效使用可用的带宽。扩展socket API 支持的通信范例,以允许跨内核边界共享内存,允许将数据传送到应用程序的低延迟机制。另外,因为具有多个主动接口的设备正在成为网络系统的标准,多宿主的支持也应该成为socket API 的一个特性。