Linux 网络编程,第 1 部分

作者:Ivan Griffin

像大多数其他基于 Unix 的操作系统一样,Linux 支持 TCP/IP 作为其原生网络传输协议。在本系列文章中,我们假定您相当熟悉 Linux 上的 C 编程以及 Linux 主题,如信号、forking 等。

本文是对使用 BSD 套接字接口 创建网络应用程序的基本介绍。在下一篇文章中,我们将讨论创建(网络)守护进程所涉及的问题。未来的文章将涵盖使用远程过程调用和使用 CORBA/分布式对象进行开发。

TCP/IP 简要介绍

TCP/IP 协议套件允许运行在同一台计算机或通过网络连接的不同计算机上的两个应用程序进行通信。它专门设计用于容忍不可靠的网络。TCP/IP 允许两种基本操作模式——面向连接的可靠传输和无连接的不可靠传输(分别为 TCP 和 UDP)。图 1 说明了 TCP/IP 协议套件堆栈中不同的协议层。

图 1. TCP/IP 协议层

TCP 提供排序的、可靠的、双向的、基于连接的字节流,并具有透明的重传。用英语来说,TCP 将您的消息分解成块(大小不超过 64KB),并确保所有块都无错误地按正确顺序到达目的地。由于是基于连接的,因此必须在网络实体之间建立虚拟连接后才能进行通信。UDP 提供(非常快速的)无连接的、不可靠的消息传输(最大长度固定)。

为了允许应用程序相互通信,无论是在同一台机器上(使用环回)还是跨不同的主机,每个应用程序都必须是可单独寻址的。

TCP/IP 地址由两部分组成——用于标识机器的 IP 地址和用于标识在该机器上运行的特定应用程序的端口号。

地址通常以“点分四段”表示法(即 127.0.0.1)或主机名(foobar.bundy.org)给出。系统可以使用 /etc/hosts 文件或 域名服务 (DNS)(如果可用)将主机名转换为主机地址。

端口号的范围从 1 向上。1 到 IPPORT_RESERVED 之间的端口(在 /usr/include/netinet/in.h 中定义——通常为 1024)保留供系统使用(即,您必须是 root 用户才能创建服务器以绑定到这些端口)。

最简单的网络应用程序遵循 客户端-服务器 模型。服务器进程等待客户端进程连接到它。当连接建立后,服务器代表客户端执行一些任务,然后通常会断开连接。

使用 BSD 套接字接口

TCP/IP 编程最流行的方法是使用 BSD 套接字接口。使用此接口,网络端点(IP 地址和端口号)表示为 套接字

套接字进程间通信 (IPC) 工具(在 4.2BSD 中引入)旨在允许独立于底层通信工具构建基于网络的应用程序。

创建服务器应用程序

要使用 BSD 接口创建服务器应用程序,您必须遵循以下步骤

  1. 通过键入以下内容创建一个新套接字:socket()

  2. 通过键入以下内容将地址(IP 地址和端口号)绑定到套接字:bind。此步骤标识服务器,以便客户端知道去哪里。

  3. 通过键入以下内容监听套接字上的新连接请求:listen()

  4. 通过键入以下内容接受新连接:accept()

通常,代表客户端处理请求可能需要相当长的时间。在这种情况下,更有效的方法是在处理请求的同时接受和处理新连接。最常见的做法是让服务器在接受新连接后 fork 自身的副本。

图 2. 客户端/服务器代码表示

列表 1 中的代码示例展示了如何在 C 中实现服务器。该程序期望仅使用一个命令行参数调用:要绑定的端口号。然后,它创建一个新套接字以使用 socket() 系统调用进行监听。此调用接受三个参数:要监听的套接字类型网络协议

域可以是 PF_UNIX 域(即,仅限于本地机器内部)或 PF_INET(即,来自 Internet 的所有请求)。套接字类型指定连接的通信语义。虽然已指定了几种类型的套接字,但在实践中,SOCK_STREAM 和 SOCK_DGRAM 是最流行的实现。SOCK_STREAM 提供 TCP 可靠的面向连接的通信,SOCK_DGRAM 提供 UDP 无连接通信。

protocol 参数标识要与套接字一起使用的特定协议。虽然在给定的协议族(或域)中可能存在多个协议,但通常只有一个。对于 TCP,它是 IPPROTO_TCP,对于 UDP,它是 IPPROTO_UDP。进行函数调用时,您不必显式指定此参数。相反,使用值 0 将选择默认协议。

创建套接字后,可以通过套接字选项调整其操作。在上面的示例中,套接字设置为重用旧地址(即,IP 地址 + 端口号),而无需等待所需的连接关闭超时。如果不设置此选项,您将必须在 TIME_WAIT 状态下等待四分钟才能再次使用该地址。四分钟来自 2 * MSL。RFC 1337 建议的 MSL 值为 120 秒。Linux 使用 60 秒,BSD 实现通常使用大约 30 秒。

套接字可以延迟关闭,以确保在一个端关闭后读取所有数据。此选项在代码中已启用。linger 的结构在 /usr/include/linux/socket.h 中定义。它看起来像这样

struct linger
{
        int l_onoff;   /* Linger active */
        int l_linger;  /* How long to linger */
};

如果 l_onoff 为零,则禁用延迟关闭。如果它为非零值,则为套接字启用延迟关闭。l_linger 字段指定延迟关闭时间(以秒为单位)。

然后,服务器尝试发现自己的主机名。我可以使用 gethostname() 调用,但在 SVR4 Unix(即,Sun 的 Solaris、SCO Unixware 和 buddies)中,不建议使用此函数,因此本地函数 _GetHostName() 提供了更可移植的解决方案。

一旦建立主机名,服务器就会通过尝试使用 gethostbyname() 调用将主机名解析为 Internet 域地址,从而构造套接字的地址。服务器的 IP 地址可以设置为 INADDR_ANY,以允许客户端在其任何 IP 地址上联系服务器——例如,用于具有多个网卡或每个网卡具有多个地址的机器。

创建地址后,将其绑定到套接字。现在可以使用该套接字来监听新连接。BACK_LOG 指定挂起连接的监听队列的最大大小。如果连接请求在监听队列已满时到达,则会因连接被拒绝错误而失败。[这构成了拒绝服务攻击的一种类型的基础——编者注。] 请参阅关于 TCP listen() 积压的侧边栏。

在表明愿意监听新连接请求后,套接字随后准备接受请求并为其提供服务。示例代码使用无限 for() 循环来实现此目的。一旦接受连接,服务器就可以确定客户端的地址以用于日志记录或其他目的。然后,它 fork 自身的子副本以处理请求,而它(父进程)继续监听和接受新请求。

子进程可以使用此连接上的 read()write() 系统调用与客户端通信。也可以在这些连接上使用缓冲 I/O(例如,fprint()),只要您记得在必要时 fflush() 输出即可。或者,您可以完全禁用进程的缓冲(请参阅 setvbuf() (3) 手册页)。

正如您从代码中看到的,当使用这种简单的 fork 模型时,子进程关闭继承的父套接字文件描述符,而父进程关闭子套接字描述符是很常见(并且是好的做法)的。

创建相应的客户端

列表 2 中显示的客户端代码比相应的服务器代码稍微简单一些。要启动客户端,您必须提供两个命令行参数:服务器运行所在机器的主机名或地址以及服务器绑定的端口号。显然,服务器必须先运行,任何客户端才能连接到它。

在客户端示例(列表 2)中,像以前一样创建了一个套接字。第一个命令行参数首先被假定为用于查找服务器地址的主机名。如果失败,则假定它是点分四段 IP 地址。如果这也失败,则客户端无法解析服务器的地址,并且将无法联系到它。

找到服务器后,为客户端套接字创建地址结构。此处不需要显式调用 bind(),因为 connect() 调用会处理所有这些。

一旦 connect() 成功返回,就建立了双工连接。与服务器一样,客户端现在可以使用 read() 和 write() 调用来接收连接上的数据。

在通过套接字连接发送数据时,请注意以下几点

  • 发送文本通常没有问题。请记住,不同的系统对于行尾可能有不同的约定(即,Unix 是 \012,而 Microsoft 使用 \015\012)。

  • 不同的架构可能对整数等使用不同的字节顺序。值得庆幸的是,BSD 的家伙们已经考虑过这个问题。有一些例程(短整数的 htonsnstoh,长整数的 htonlntohl)执行主机到网络顺序和网络到主机顺序的转换。网络顺序是小端还是大端并不重要。它已在所有 TCP/IP 网络堆栈实现中标准化。除非您始终只通过套接字传递字符,否则如果您不使用这些例程,您将遇到字节顺序问题。根据机器架构,这些例程可能是空宏,也可能是实际的功能。有趣的是,套接字编程中一个常见的错误来源是忘记使用这些字节顺序例程来填充 sock_addr 结构中的地址字段。也许这不是很直观,但当使用 INADDR_ANY 时也必须这样做(即,htonl(INADDR_ANY))。

  • 网络编程的一个关键目标是确保进程不会以意外的方式相互干扰。特别是,服务器必须使用适当的机制来序列化通过代码关键部分的入口,避免死锁并保护数据有效性。

  • 您不能(通常)将指向内存的指针从一台机器传递到另一台机器,并期望使用它。您不太可能想要这样做。

  • 同样,您不能(通常)通过套接字将文件描述符从一个进程传递到另一个(非子进程)进程,并立即使用它。BSD 和 SVR4 都提供了在不相关的进程之间传递文件描述符的不同方法;但是,在 Linux 中执行此操作的最简单方法是使用 /proc 文件系统。

此外,您必须确保正确处理短写。当 write() 调用仅部分将缓冲区写入文件描述符时,会发生短写。它们的发生是由于操作系统中的缓冲和底层传输协议中的流控制。某些系统调用,称为 慢速 系统调用,可能会被中断。有些可能会自动重启,有些可能不会,因此您应该在网络编程时显式处理这种情况。列表 3 中的代码摘录处理了短写。

使用多个 线程 而不是多个进程可能会减轻服务器主机的负载,从而提高效率。上下文切换 线程(在同一进程地址空间中)通常比在不同进程之间切换具有更少的关联开销。但是,由于在这种情况下大多数从属线程都在进行网络 I/O,因此它们必须是内核级线程。如果它们是用户级线程,则第一个在 I/O 上阻塞的线程将导致整个进程阻塞。这将导致所有其他线程在 I/O 完成之前都无法获得任何 CPU 注意力。

当使用简单的 fork 模型时,在子进程和父进程中关闭不必要的套接字文件描述符是很常见的。这可以防止子进程或父进程发生潜在的错误读取或写入,并释放描述符,描述符是一种有限的资源。但是,当使用线程时,请勿尝试这样做。进程中的多个线程共享相同的内存空间和文件描述符集。如果您在从属线程中关闭服务器套接字,则它会关闭该进程中的所有其他线程。

无连接数据——UDP

列表 4 显示了使用 UDP 的无连接服务器。虽然 UDP 应用程序与其 TCP 应用程序类似,但它们有一些重要的区别。最重要的是,UDP 不保证可靠交付——如果您需要可靠性并且正在使用 UDP,则您必须在应用程序逻辑中自己实现它,或者切换到 TCP。

与 TCP 应用程序一样,使用 UDP,您可以创建一个套接字并将地址绑定到它。(某些 UDP 服务器不需要调用 bind(),但它没有坏处,并且可以避免您犯错误。)UDP 服务器不监听或接受传入连接,客户端不显式连接到服务器。实际上,UDP 客户端和服务器之间几乎没有区别。服务器必须绑定到已知的端口和地址,以便客户端知道将消息发送到哪里。此外,预期数据传输的顺序是相反的,即,当您在服务器中使用 send() 发送数据时,您的客户端应期望使用 recv() 接收数据。

UDP 客户端通常会使用 sin_port 值为 0 填充 sockaddr_in 结构。(请注意,任何字节顺序的 0 都是 0。)然后,系统会自动为客户端分配一个未使用的端口号(介于 1024 和 5000 之间)。我将把列表 4 中的服务器转换为 UDP 客户端作为读者的练习。

/etc/services

为了连接到服务器,您必须首先知道服务器正在监听的地址和端口号。许多常用服务(FTP、TELNET 等)都列在一个名为 /etc/services 的文本数据库文件中。存在一个接口,可以通过名称请求服务并接收该服务的端口号(以网络字节顺序正确格式化)。该函数是 getservbyname(),其原型位于头文件 /usr/include/netdb.h 中。此示例采用服务名称和协议类型,并返回指向 struct servent 的指针。

struct servent
{
        char *s_name;   /* official service name */
        char **s_aliases;       /* alias list */
        int s_port;     /* port number, network<\n>
                         * byte-order--so do not
                         * use host-to-network macros */
        char *s_proto;  /* protocol to use */
};
结论

本文介绍了 Linux 中的网络编程,使用 C 和 BSD 套接字 API。总的来说,与其他一些可用技术相比,使用此 API 进行编码往往非常费力。在未来的文章中,我将比较 BSD 套接字 API 的两种替代方案,用于 Linux:使用 远程过程调用 (RPC) 和 公共对象请求代理体系结构 (CORBA)。RPC 在 Ed Petron 的文章“远程过程调用”Linux Journal 第 42 期(1997 年 10 月)中介绍。

资源

TCP listen() 积压

主要系统调用 本系列文章的下一篇将涵盖在 Linux 中开发长期运行的网络服务(守护进程)所涉及的问题。

本文中引用的所有列表都可以通过匿名下载文件 ftp://ftp.linuxjournal.com/lj/listings/issue46/2333.tgz 获得。

Ivan Griffin (ivan.griffin@ul.ie) 是爱尔兰利默里克大学 ECE 系的研究生。他的兴趣包括 C++/Java、WWW、ATM、UL 计算机协会 (http://www.csn.ul.ie/),当然还有 Linux (http://www.trc.ul.ie/~griffini/linux.html)。

John Nelson 博士 (john.nelson@ul.ie) 是利默里克大学计算机工程高级讲师。他的兴趣包括移动通信、智能网络、软件工程和 VLSI 设计。

加载 Disqus 评论