第 7 章 套接字

7.1. 摘要

BSD 套接字将进程间通信提升到一个新的水平。通信进程不再需要运行在同一台机器上。它们仍然可以,但不必如此。

这些进程不仅不必运行在同一台机器上,而且不必运行在相同的操作系统下。感谢 BSD 套接字,您的 FreeBSD 软件可以与运行在 Macintosh® 上的程序、运行在 Sun™ 工作站上的另一个程序以及运行在 Windows® 2000 下的另一个程序平滑地协作,所有这些程序都通过基于以太网的局域网连接。

但是您的软件也可以与运行在另一栋建筑物中或另一大陆上、潜艇内或航天飞机中的进程协作。

它还可以与不是计算机一部分(至少不是严格意义上的计算机)的进程协作,而是与打印机、数码相机、医疗设备等设备协作。几乎任何能够进行数字通信的设备。

7.2. 网络和多样性

我们已经暗示了网络的多样性。许多不同的系统必须相互通信。而且它们必须使用相同的语言。它们还必须以相同的方式理解相同的语言。

人们常常认为肢体语言是通用的。但事实并非如此。在我十几岁的时候,我父亲带我去了保加利亚。我们坐在索非亚公园的一张桌子旁,这时一个摊贩走过来试图向我们出售一些烤杏仁。

我那时还没有学多少保加利亚语,所以,我没有说“不”,而是左右摇头,这是表示“不”的“通用”肢体语言。摊贩很快开始给我们送杏仁。

然后我记起有人告诉我,在保加利亚左右摇头表示。我很快开始上下点头。摊贩注意到了,拿走了他的杏仁,然后走开了。对于一个不知情的人来说,我没有改变肢体语言:我继续使用摇头和点头的语言。发生变化的是肢体语言的含义。起初,摊贩和我将相同的语言解释为具有完全不同的含义。我必须调整我对该语言的理解,以便摊贩能够理解。

计算机也是如此:相同的符号可能具有不同的,甚至完全相反的含义。因此,为了让两台计算机相互理解,它们不仅必须就相同的语言达成一致,而且必须就该语言的相同解释达成一致。

7.3. 协议

虽然各种编程语言往往具有复杂的语法并使用许多多字母保留字(这使得人类程序员易于理解),但数据通信语言往往非常简洁。它们通常使用单个比特而不是多字节字。有一个非常令人信服的原因:虽然数据在您的计算机内部以接近光速的速度传输,但它在两台计算机之间传输的速度往往要慢得多。

由于数据通信中使用的语言非常简洁,因此我们通常将它们称为协议而不是语言。

当数据从一台计算机传输到另一台计算机时,它总是使用多个协议。这些协议是分层的。数据可以比作洋葱的内部:您必须剥掉几层“皮”才能到达数据。这可以用图片最好地说明

layers
图 1. 协议层

在这个例子中,我们试图从通过以太网连接的网页获取图像。

该图像由原始数据组成,它只是一个我们的软件可以处理的 RGB 值序列,即将其转换为图像并在我们的显示器上显示。

唉,我们的软件无法知道原始数据是如何组织的:它是 RGB 值序列,还是灰度强度序列,或者可能是 CMYK 编码的颜色?数据是用 8 位量子表示的吗,还是 16 位,或者可能是 4 位?图像由多少行和列组成?某些像素应该透明吗?

我想你明白了……

为了告知我们的软件如何处理原始数据,它被编码为 PNG 文件。它可以是 GIF 或 JPEG,但它是 PNG。

并且 PNG 是一种协议。

在这一点上,我听到你们中的一些人喊叫,“不,它不是!它是一种文件格式!”

好吧,它当然是一种文件格式。但从数据通信的角度来看,文件格式是一种协议:文件结构是一种语言,一种简洁的语言,告诉我们的进程数据是如何组织的。因此,它是一种协议

唉,如果我们收到的只是 PNG 文件,我们的软件将面临一个严重的问题:它如何知道数据表示图像,而不是一些文本,或者可能是声音,或者其他什么?其次,它如何知道该图像是 PNG 格式而不是 GIF 或 JPEG 或其他一些图像格式?

为了获取该信息,我们使用另一种协议:HTTP。此协议可以准确地告诉我们数据表示图像,以及它使用 PNG 协议。它还可以告诉我们其他一些事情,但让我们在这里专注于协议层。

因此,现在我们有一些数据包装在 PNG 协议中,包装在 HTTP 协议中。我们是如何从服务器获取它的?

通过在以太网上使用 TCP/IP,就是这样。确实,那是另外三个协议。我没有继续从内到外,而是现在要谈谈以太网,因为用这种方式解释其余部分更容易。

以太网是连接局域网 (LAN) 中计算机的一种有趣的系统。每台计算机都有一张网络接口卡 (NIC),它具有一个唯一的 48 位 ID,称为其地址。世界上没有两张以太网 NIC 具有相同的地址。

这些 NIC 都相互连接。每当一台计算机想要与同一以太网 LAN 中的另一台计算机通信时,它都会通过网络发送消息。每个 NIC 都可以看到该消息。但作为以太网协议的一部分,数据包含目标 NIC 的地址(以及其他内容)。因此,所有网络接口卡中只有一个会注意它,其余的将忽略它。

但并非所有计算机都连接到同一个网络。仅仅因为我们通过以太网收到了数据,并不意味着它源于我们自己的局域网。它可能是从通过互联网连接到我们自己网络的其他网络(甚至可能不是基于以太网的)传输给我们的。

所有数据都通过互联网使用 IP 传输,IP 代表互联网协议。它的基本作用是让我们知道数据从世界上的哪个地方到达,以及它应该传输到哪里。它不保证我们会收到数据,只是保证如果我们收到数据,我们会知道它来自哪里。

即使我们确实收到了数据,IP 也不能保证我们会按另一台计算机发送给我们的相同顺序接收各种数据块。例如,我们可能会在收到左上角之前和收到右下角之后收到图像的中心。

正是 TCP(传输控制协议)要求发送方重新发送任何丢失的数据,并将所有数据按正确的顺序排列。

总而言之,一台计算机需要五个不同的协议才能与另一台计算机通信图像的外观。我们收到包装在 PNG 协议中的数据,该协议包装在 HTTP 协议中,该协议包装在 TCP 协议中,该协议包装在 IP 协议中,该协议包装在以太网协议中。

哦,顺便说一句,在此过程中可能还涉及其他一些协议。例如,如果我们的 LAN 通过拨号连接到互联网,它将使用 PPP 协议通过调制解调器,该调制解调器使用各种调制解调器协议中的一种(或多种),等等,等等……

作为开发人员,您现在应该会问,“我应该如何处理所有这些?”

幸运的是,你**不必**处理所有的事情。你**需要**处理其中的一部分,但不是全部。具体来说,你无需担心物理连接(在我们的例子中是以太网和可能存在的PPP等)。你也不需要处理互联网协议或传输控制协议。

换句话说,你无需执行任何操作即可从另一台计算机接收数据。当然,你确实需要**请求**它,但这几乎和打开一个文件一样简单。

一旦你接收了数据,就由你来决定如何处理它。在我们的例子中,你需要理解HTTP协议和PNG文件结构。

打个比方,所有网络互连协议都变成了一个灰色地带:与其说我们不理解它是如何工作的,不如说我们不再关心它了。套接字接口为我们处理了这个灰色地带。

slayers
图2. 套接字覆盖的协议层

我们只需要理解任何告诉我们如何**解释数据**的协议,而无需了解如何从另一个进程**接收**它,也无需了解如何将其**发送**到另一个进程。

7.4. 套接字模型

BSD套接字建立在基本的UNIX®模型之上:**一切皆文件**。在我们的例子中,套接字可以让我们接收一个**HTTP文件**,可以这么说。然后,我们将需要从中提取**PNG文件**。

由于网络互连的复杂性,我们不能仅仅使用open系统调用或open() C函数。相反,我们需要采取几个步骤来“打开”套接字。

但是,一旦我们这样做,就可以开始像对待任何**文件描述符**一样对待**套接字**:我们可以从中读取,向其写入,对其进行管道操作,并最终将其关闭

7.5. 必要的套接字函数

虽然FreeBSD提供了不同的函数来处理套接字,但我们只需要四个来“打开”套接字。在某些情况下,我们只需要两个。

7.5.1. 客户端-服务器差异

通常,基于套接字的数据通信的一端是**服务器**,另一端是**客户端**。

7.5.1.1. 公共元素

7.5.1.1.1. socket

客户端和服务器都使用的一个函数是socket(2)。它的声明方式如下

int socket(int domain, int type, int protocol);

返回值与open的类型相同,一个整数。FreeBSD从与文件句柄相同的池中分配其值。这就是允许套接字像文件一样处理的原因。

domain参数告诉系统你希望它使用什么**协议族**。许多协议族存在,有些是特定于供应商的,有些则非常常见。它们在sys/socket.h中声明。

对于UDP、TCP和其他互联网协议(IPv4),请使用PF_INET

type参数定义了五个值,同样在sys/socket.h中。它们都以“SOCK_”开头。最常见的是SOCK_STREAM,它告诉系统你正在请求**可靠的流交付服务**(当与PF_INET一起使用时,它是TCP)。

如果你请求了SOCK_DGRAM,则表示你正在请求**无连接的数据报交付服务**(在我们的例子中,是UDP)。

如果你想负责低级协议(例如IP)甚至网络接口(例如以太网),则需要指定SOCK_RAW

最后,protocol参数取决于前两个参数,并不总是具有意义。在这种情况下,请使用0作为其值。

未连接的套接字

socket函数中,我们没有指定应该连接到哪个其他系统。我们新创建的套接字仍然是**未连接的**。

这是有意的:打个电话的比方,我们只是将调制解调器连接到电话线上。我们既没有告诉调制解调器拨打电话,也没有告诉它在电话响起时接听。

7.5.1.1.2. sockaddr

套接字系列的各种函数都期望内存中一个小区域的地址(或指针,使用C术语)。sys/socket.h中的各种C声明将其称为struct sockaddr。此结构在同一文件中声明

/*
 * Structure used by kernel to store most
 * addresses.
 */
struct sockaddr {
	unsigned char	sa_len;		/* total length */
	sa_family_t	sa_family;	/* address family */
	char		sa_data[14];	/* actually longer; address value */
};
#define	SOCK_MAXADDRLEN	255		/* longest possible addresses */

请注意sa_data字段声明的**模糊性**,它只是一个14字节的数组,注释暗示可能存在超过14个字节。

这种模糊性是经过深思熟虑的。套接字是一个非常强大的接口。虽然大多数人可能认为它只不过是互联网接口——而且如今大多数应用程序可能都用它来实现这一点——但套接字几乎可以用于**任何**类型的进程间通信,其中互联网(或更准确地说,IP)只是一种。

sys/socket.h将套接字将处理的各种类型的协议称为**地址族**,并在sockaddr的定义之前列出它们。

/*
 * Address families.
 */
#define	AF_UNSPEC	0		/* unspecified */
#define	AF_LOCAL	1		/* local to host (pipes, portals) */
#define	AF_UNIX		AF_LOCAL	/* backward compatibility */
#define	AF_INET		2		/* internetwork: UDP, TCP, etc. */
#define	AF_IMPLINK	3		/* arpanet imp addresses */
#define	AF_PUP		4		/* pup protocols: e.g. BSP */
#define	AF_CHAOS	5		/* mit CHAOS protocols */
#define	AF_NS		6		/* XEROX NS protocols */
#define	AF_ISO		7		/* ISO protocols */
#define	AF_OSI		AF_ISO
#define	AF_ECMA		8		/* European computer manufacturers */
#define	AF_DATAKIT	9		/* datakit protocols */
#define	AF_CCITT	10		/* CCITT protocols, X.25 etc */
#define	AF_SNA		11		/* IBM SNA */
#define AF_DECnet	12		/* DECnet */
#define AF_DLI		13		/* DEC Direct data link interface */
#define AF_LAT		14		/* LAT */
#define	AF_HYLINK	15		/* NSC Hyperchannel */
#define	AF_APPLETALK	16		/* Apple Talk */
#define	AF_ROUTE	17		/* Internal Routing Protocol */
#define	AF_LINK		18		/* Link layer interface */
#define	pseudo_AF_XTP	19		/* eXpress Transfer Protocol (no AF) */
#define	AF_COIP		20		/* connection-oriented IP, aka ST II */
#define	AF_CNT		21		/* Computer Network Technology */
#define pseudo_AF_RTIP	22		/* Help Identify RTIP packets */
#define	AF_IPX		23		/* Novell Internet Protocol */
#define	AF_SIP		24		/* Simple Internet Protocol */
#define	pseudo_AF_PIP	25		/* Help Identify PIP packets */
#define	AF_ISDN		26		/* Integrated Services Digital Network*/
#define	AF_E164		AF_ISDN		/* CCITT E.164 recommendation */
#define	pseudo_AF_KEY	27		/* Internal key-management function */
#define	AF_INET6	28		/* IPv6 */
#define	AF_NATM		29		/* native ATM access */
#define	AF_ATM		30		/* ATM */
#define pseudo_AF_HDRCMPLT 31		/* Used by BPF to not rewrite headers
					 * in interface output routine
					 */
#define	AF_NETGRAPH	32		/* Netgraph sockets */
#define	AF_SLOW		33		/* 802.3ad slow protocol */
#define	AF_SCLUSTER	34		/* Sitara cluster protocol */
#define	AF_ARP		35
#define	AF_BLUETOOTH	36		/* Bluetooth sockets */
#define	AF_MAX		37

用于IP的是AF_INET。它是常量2的符号。

正是sockaddrsa_family字段中列出的**地址族**决定了sa_data的模糊命名字节将如何使用。

具体来说,每当**地址族**为AF_INET时,我们都可以在期望sockaddr的任何地方使用netinet/in.h中找到的struct sockaddr_in

/*
 * Socket address, internet style.
 */
struct sockaddr_in {
	uint8_t		sin_len;
	sa_family_t	sin_family;
	in_port_t	sin_port;
	struct	in_addr sin_addr;
	char	sin_zero[8];
};

我们可以这样可视化它的组织结构

sain
图3. sockaddr_in结构

三个重要的字段是sin_family(结构的第1个字节)、sin_port(在第2和第3个字节中找到的16位值)和sin_addr(IP地址的32位整数表示形式,存储在第4-7个字节中)。

现在,让我们尝试填写它。假设我们正在尝试为**日期时间**协议编写一个客户端,该协议简单地指出其服务器将写入一个表示当前日期和时间的文本字符串到端口13。我们希望使用TCP/IP,因此我们需要在地址族字段中指定AF_INETAF_INET定义为2。让我们使用192.43.244.18的IP地址,它是美国联邦政府的时间服务器(time.nist.gov)。

sainfill
图4. sockaddr_in的具体示例

顺便说一句,sin_addr字段声明为struct in_addr类型,该类型在netinet/in.h中定义。

/*
 * Internet address (a structure for historical reasons)
 */
struct in_addr {
	in_addr_t s_addr;
};

此外,in_addr_t是一个32位整数。

192.43.244.18只是通过列出其所有8位字节(从**最高有效位**开始)来表达32位整数的一种方便的表示法。

到目前为止,我们已经将sockaddr视为一个抽象概念。我们的计算机不将short整数存储为单个16位实体,而是存储为2个字节的序列。类似地,它将32位整数存储为4个字节的序列。

假设我们编写了类似以下内容的代码

sa.sin_family      = AF_INET;
sa.sin_port        = 13;
sa.sin_addr.s_addr = (((((192 << 8) | 43) << 8) | 244) << 8) | 18;

结果会是什么样子呢?

好吧,这当然取决于情况。在Pentium®或其他基于x86的计算机上,它将如下所示

sainlsb
图5. Intel系统上的sockaddr_in

在不同的系统上,它可能如下所示

sainmsb
图6. MSB系统上的sockaddr_in

在PDP上,它可能看起来又有所不同。但是以上两种是当今最常用的方式。

通常,为了编写可移植的代码,程序员会假装这些差异不存在。并且他们能够做到这一点(除非他们用汇编语言编写代码)。唉,在为套接字编写代码时,你无法轻易地做到这一点。

为什么?

因为在与另一台计算机通信时,你通常不知道它是否将数据存储为**最高有效字节**(MSB)或**最低有效字节**(LSB)优先。

你可能想知道,“那么,套接字不会帮我处理这个问题吗?”

不会。

虽然这个答案一开始可能会让你感到惊讶,但请记住,通用的套接字接口只理解sockaddr结构的sa_lensa_family字段。你无需担心那里的字节顺序(当然,在FreeBSD上,sa_family只有1个字节,但许多其他UNIX®系统没有sa_len,并使用2个字节表示sa_family,并期望数据以计算机的本机顺序排列)。

但对于套接字而言,其余数据只是sa_data[14]。根据**地址族**的不同,套接字会将其数据转发到其目标。

确实,当我们输入端口号时,是因为我们希望另一台计算机知道我们请求的服务是什么。而且,当我们是服务器时,我们读取端口号以便知道另一台计算机期望我们提供什么服务。无论哪种方式,套接字都只需要将端口号作为数据转发。它不会以任何方式解释它。

同样,我们输入IP地址是为了告诉沿途的所有人将我们的数据发送到哪里。套接字再次将其仅作为数据转发。

这就是为什么我们(**程序员**,而不是**套接字**)必须区分计算机使用的字节顺序和发送到另一台计算机的约定字节顺序。

我们将计算机使用的字节顺序称为**主机字节顺序**或简称**主机顺序**。

有一种约定是通过IP以**MSB优先**的方式发送多字节数据。我们将此称为**网络字节顺序**或简称**网络顺序**。

现在,如果我们为基于Intel的计算机编译了上述代码,我们的**主机字节顺序**将产生

sainlsb
图7. Intel系统上的主机字节顺序

但**网络字节顺序**要求我们先存储MSB。

sainmsb
图8. 网络字节顺序

不幸的是,我们的**主机顺序**与**网络顺序**完全相反。

我们有几种方法可以处理它。一种是在代码中**反转**值

sa.sin_family      = AF_INET;
sa.sin_port        = 13 << 8;
sa.sin_addr.s_addr = (((((18 << 8) | 244) << 8) | 43) << 8) | 192;

这将“欺骗”我们的编译器以**网络字节顺序**存储数据。在某些情况下,这正是执行此操作的方式(例如,在用汇编语言编程时)。但是,在大多数情况下,这可能会导致问题。

假设,你用C编写了一个基于套接字的程序。你知道它将在Pentium®上运行,因此你以反向方式输入所有常量并强制它们使用**网络字节顺序**。它运行良好。

然后,有一天,你信任的旧Pentium®变成了一个生锈的旧Pentium®。你用一个**主机顺序**与**网络顺序**相同的系统替换它。你需要重新编译所有软件。你所有的软件都继续运行良好,除了你编写的那一个程序。

你已经忘记了你曾将所有常量强制为**主机顺序**的反面。你花了一些时间扯头发,叫喊你曾经听过(以及一些你编造的)所有神灵的名字,用软弹枪击打你的显示器,并执行所有其他试图弄清楚为什么以前运行良好的东西突然完全无法运行的传统仪式。

最终,你弄明白了,说了一些脏话,然后开始重写你的代码。

幸运的是,你不是第一个遇到这个问题的人。其他人已经创建了htons(3)htonl(3) C函数,分别将shortlong从**主机字节顺序**转换为**网络字节顺序**,以及ntohs(3)ntohl(3) C函数,用于反向转换。

在**MSB优先**系统上,这些函数什么也不做。在**LSB优先**系统上,它们将值转换为正确的顺序。

所以,无论您的软件在哪个系统上编译,如果您使用这些函数,您的数据最终都将按正确的顺序排列。

7.5.1.2. 客户端函数

通常,客户端发起与服务器的连接。客户端知道它即将调用哪个服务器:它知道服务器的 IP 地址,也知道服务器所在的端口。这就像您拿起电话拨打电话号码(地址),然后在有人接听后,询问负责 Wingdings 的人(端口)。

7.5.1.2.1. connect

一旦客户端创建了一个套接字,它需要将其连接到远程系统上的特定端口。它使用 connect(2)

int connect(int s, const struct sockaddr *name, socklen_t namelen);

s 参数是套接字,即 socket 函数返回的值。name 是指向 sockaddr 的指针,这是我们已经广泛讨论过的结构。最后,namelen 通知系统我们的 sockaddr 结构中有多少字节。

如果 connect 成功,它将返回 0。否则,它将返回 -1 并将错误代码存储在 errno 中。

connect 失败的原因有很多。例如,尝试进行 Internet 连接时,IP 地址可能不存在,或者可能已关闭,或者太忙,或者可能在指定的端口上没有监听的服务器。或者它可能会完全拒绝对特定代码的任何请求。

7.5.1.2.2. 我们的第一个客户端

我们现在已经了解了足够的信息来编写一个非常简单的客户端,它将从 192.43.244.18 获取当前时间并将其打印到 stdout

/*
 * daytime.c
 *
 * Programmed by G. Adam Stanislav
 */
#include <stdio.h>
#include <string.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>

int main() {
  int s, bytes;
  struct sockaddr_in sa;
  char buffer[BUFSIZ+1];

  if ((s = socket(PF_INET, SOCK_STREAM, 0)) < 0) {
    perror("socket");
    return 1;
  }

  memset(&sa, '\0', sizeof(sa));

  sa.sin_family = AF_INET;
  sa.sin_port = htons(13);
  sa.sin_addr.s_addr = htonl((((((192 << 8) | 43) << 8) | 244) << 8) | 18);
  if (connect(s, (struct sockaddr *)&sa, sizeof sa) < 0) {
    perror("connect");
    close(s);
    return 2;
  }

  while ((bytes = read(s, buffer, BUFSIZ)) > 0)
    write(1, buffer, bytes);

  close(s);
  return 0;
}

继续,在您的编辑器中输入它,将其保存为 daytime.c,然后编译并运行它。

% cc -O3 -o daytime daytime.c
% ./daytime

52079 01-06-19 02:29:25 50 0 1 543.9 UTC(NIST) *
%

在这种情况下,日期是 2001 年 6 月 19 日,时间是 02:29:25 UTC。当然,您的结果会有所不同。

7.5.1.3. 服务器函数

典型的服务器不会发起连接。相反,它会等待客户端调用它并请求服务。它不知道客户端何时会调用,也不知道有多少客户端会调用。它可能只是坐在那里,耐心地等待,一瞬间,下一瞬间,它可能会发现自己被来自许多客户端的请求淹没,所有客户端都在同一时间打电话。

套接字接口提供三个基本函数来处理此问题。

7.5.1.3.1. bind

端口就像电话线的扩展:拨打号码后,拨打分机号码以连接到特定的人或部门。

有 65535 个 IP 端口,但服务器通常只处理来自其中一个端口的请求。这就像告诉电话接线员我们现在上班了,并且可以在特定的分机号码上接听电话。我们使用 bind(2) 来告诉套接字我们想要服务哪个端口。

int bind(int s, const struct sockaddr *addr, socklen_t addrlen);

除了在 addr 中指定端口外,服务器还可以包含其 IP 地址。但是,它可以使用符号常量 INADDR_ANY 来指示它将服务所有对指定端口的请求,而不管其 IP 地址是什么。此符号以及几个类似的符号在 netinet/in.h 中声明。

#define	INADDR_ANY		(u_int32_t)0x00000000

假设我们正在为 TCP/IP 上的日期时间协议编写服务器。回想一下,它使用端口 13。我们的 sockaddr_in 结构将如下所示

sainserv
图 9. 示例服务器 sockaddr_in
7.5.1.3.2. listen

继续我们的办公室电话类比,在您告诉电话总机接线员您将在哪个分机号码后,您现在走进您的办公室,并确保您自己的电话已插入并且铃声已打开。此外,您确保您的呼叫等待已激活,以便即使您正在与某人交谈也能听到电话铃声。

服务器使用 listen(2) 函数确保所有这些。

int listen(int s, int backlog);

在这里,backlog 变量告诉套接字在您忙于处理上一个请求时接受多少个传入请求。换句话说,它决定了挂起连接队列的最大大小。

7.5.1.3.3. accept

听到电话铃声后,您通过接听电话来接听电话。您现在已与您的客户端建立连接。此连接保持活动状态,直到您或您的客户端挂断电话。

服务器使用 accept(2) 函数接受连接。

int accept(int s, struct sockaddr *addr, socklen_t *addrlen);

请注意,这次 addrlen 是一个指针。这是必要的,因为在这种情况下,是套接字填写 addr,即 sockaddr_in 结构。

返回值是整数。实际上,accept 返回一个新的套接字。您将使用此新套接字与客户端通信。

旧套接字会发生什么?它继续监听更多请求(记住我们传递给 listenbacklog 变量?)直到我们 close 它。

现在,新套接字仅用于通信。它已完全连接。我们不能再次将其传递给 listen,尝试接受其他连接。

7.5.1.3.4. 我们的第一个服务器

我们的第一个服务器将比我们的第一个客户端复杂一些:我们不仅有更多套接字函数可以使用,而且需要将其编写为守护进程。

最好在绑定端口后创建子进程来实现这一点。然后,主进程退出并将控制权返回给 shell(或调用它的任何程序)。

子进程调用 listen,然后启动一个无限循环,该循环接受连接、为其提供服务,并最终关闭其套接字。

/*
 * daytimed - a port 13 server
 *
 * Programmed by G. Adam Stanislav
 * June 19, 2001
 */
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>

#define BACKLOG 4

int main() {
    int s, c;
    socklen_t b;
    struct sockaddr_in sa;
    time_t t;
    struct tm *tm;
    FILE *client;

    if ((s = socket(PF_INET, SOCK_STREAM, 0)) < 0) {
        perror("socket");
        return 1;
    }

    memset(&sa, '\0', sizeof(sa));

    sa.sin_family = AF_INET;
    sa.sin_port   = htons(13);

    if (INADDR_ANY)
        sa.sin_addr.s_addr = htonl(INADDR_ANY);

    if (bind(s, (struct sockaddr *)&sa, sizeof sa) < 0) {
        perror("bind");
        return 2;
    }

    switch (fork()) {
        case -1:
            perror("fork");
            return 3;
        default:
            close(s);
            return 0;
        case 0:
            break;
    }

    listen(s, BACKLOG);

    for (;;) {
        b = sizeof sa;

        if ((c = accept(s, (struct sockaddr *)&sa, &b)) < 0) {
            perror("daytimed accept");
            return 4;
        }

        if ((client = fdopen(c, "w")) == NULL) {
            perror("daytimed fdopen");
            return 5;
        }

        if ((t = time(NULL)) < 0) {
            perror("daytimed time");
            return 6;
        }

        tm = gmtime(&t);
        fprintf(client, "%.4i-%.2i-%.2iT%.2i:%.2i:%.2iZ\n",
            tm->tm_year + 1900,
            tm->tm_mon + 1,
            tm->tm_mday,
            tm->tm_hour,
            tm->tm_min,
            tm->tm_sec);

        fclose(client);
    }
}

我们首先创建一个套接字。然后我们在 sa 中填写 sockaddr_in 结构。请注意 INADDR_ANY 的条件使用情况

if (INADDR_ANY)
        sa.sin_addr.s_addr = htonl(INADDR_ANY);

它的值为 0。由于我们刚刚对整个结构使用了 bzero,因此再次将其设置为 0 将是多余的。但是,如果我们将我们的代码移植到其他系统(其中 INADDR_ANY 可能不是零),我们需要将其分配给 sa.sin_addr.s_addr。大多数现代 C 编译器都足够聪明,可以注意到 INADDR_ANY 是一个常量。只要它是零,它们就会将整个条件语句从代码中优化掉。

成功调用 bind 后,我们就可以成为守护进程了:我们使用 fork 创建一个子进程。在父进程和子进程中,s 变量都是我们的套接字。父进程将不需要它,因此它调用 close,然后它返回 0 以通知其自己的父进程它已成功终止。

同时,子进程在后台继续工作。它调用 listen 并将其 backlog 设置为 4。它不需要在这里使用较大的值,因为日期时间不是许多客户端一直请求的协议,并且因为它可以立即处理每个请求。

最后,守护进程启动一个无限循环,执行以下步骤

  1. 调用 accept。它在这里等待,直到客户端联系它。此时,它接收一个新的套接字 c,它可以使用它与这个特定的客户端通信。

  2. 它使用 C 函数 fdopen 将套接字从低级文件描述符转换为 C 样式的 FILE 指针。这将允许以后使用 fprintf

  3. 它检查时间,并以ISO 8601格式将其打印到 client“文件”。然后它使用 fclose 关闭文件。这也会自动关闭套接字。

我们可以概括这一点,并将其用作许多其他服务器的模型

serv
图 10. 顺序服务器

此流程图适用于顺序服务器,即一次只能服务一个客户端的服务器,就像我们能够使用日期时间服务器一样。只有在客户端和服务器之间没有真正的“对话”时才有可能:一旦服务器检测到与客户端的连接,它就会发送一些数据并关闭连接。整个操作可能需要纳秒,并且已完成。

此流程图的优点是,除了父进程fork之后和它退出之前的那一小段时间外,始终只有一个进程处于活动状态:我们的服务器不会占用太多内存和其他系统资源。

请注意,我们在流程图中添加了初始化守护进程。我们不需要初始化我们自己的守护进程,但这是在程序流程中设置任何signal处理程序、打开我们可能需要的任何文件等的好地方。

流程图中的几乎所有内容都可以在许多不同的服务器上逐字使用。服务条目是例外。我们将其视为一个“黑盒”,即专门为您的服务器设计的内容,只需“将其插入其余部分”。

并非所有协议都那么简单。许多协议接收来自客户端的请求,对其进行回复,然后接收来自同一客户端的另一个请求。因此,他们事先不知道将为客户端服务多长时间。此类服务器通常为每个客户端启动一个新进程。当新进程正在为其客户端提供服务时,守护进程可以继续监听更多连接。

现在,继续,将上述源代码保存为 daytimed.c(习惯上以字母d结尾守护进程的名称)。编译完成后,尝试运行它。

% ./daytimed
bind: Permission denied
%

这里发生了什么?您会记得,日期时间协议使用端口 13。但所有低于 1024 的端口都保留给超级用户(否则,任何人都可以启动一个守护进程,假装服务一个常用的端口,从而造成安全漏洞)。

再试一次,这次以超级用户身份尝试。

# ./daytimed
#

什么……什么也没有?让我们再试一次

# ./daytimed

bind: Address already in use
#

每个端口一次只能被一个程序绑定。我们的第一次尝试确实成功了:它启动了子守护进程并静默返回。它仍在运行,并将继续运行,直到您终止它,或其任何系统调用失败,或您重新启动系统。

好的,我们知道它在后台运行。但它工作了吗?我们怎么知道它是一个正确的daytime服务器?很简单

% telnet localhost 13

Trying ::1...
telnet: connect to address ::1: Connection refused
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
2001-06-19T21:04:42Z
Connection closed by foreign host.
%

telnet尝试了新的IPv6,并失败了。它使用IPv4重试并成功了。守护进程工作正常。

如果您可以通过telnet访问另一个UNIX®系统,则可以使用它来测试远程访问服务器。我的电脑没有静态IP地址,所以我做了以下操作

% who

whizkid          ttyp0   Jun 19 16:59   (216.127.220.143)
xxx              ttyp1   Jun 19 16:06   (xx.xx.xx.xx)
% telnet 216.127.220.143 13

Trying 216.127.220.143...
Connected to r47.bfm.org.
Escape character is '^]'.
2001-06-19T21:31:11Z
Connection closed by foreign host.
%

同样,它成功了。使用域名会工作吗?

% telnet r47.bfm.org 13

Trying 216.127.220.143...
Connected to r47.bfm.org.
Escape character is '^]'.
2001-06-19T21:31:40Z
Connection closed by foreign host.
%

顺便说一句,在我们的守护进程关闭套接字后,telnet会打印Connection closed by foreign host消息。这向我们表明,确实,在我们代码中使用fclose(client);可以按预期工作。

7.6. 辅助函数

FreeBSD C库包含许多用于套接字编程的辅助函数。例如,在我们的示例客户端中,我们硬编码了time.nist.gov的IP地址。但我们并不总是知道IP地址。即使我们知道,如果我们的软件允许用户输入IP地址,甚至域名,则会更加灵活。

7.6.1. gethostbyname

虽然无法将域名直接传递给任何套接字函数,但FreeBSD C库提供了gethostbyname(3)gethostbyname2(3)函数,声明在netdb.h中。

struct hostent * gethostbyname(const char *name);
struct hostent * gethostbyname2(const char *name, int af);

两者都返回指向hostent结构的指针,其中包含有关域名的许多信息。对于我们的目的,结构的h_addr_list[0]字段指向h_length字节的正确地址,这些地址已经存储在网络字节序中。

这使我们能够创建更灵活且更有用的daytime程序版本

/*
 * daytime.c
 *
 * Programmed by G. Adam Stanislav
 * 19 June 2001
 */
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <netdb.h>

int main(int argc, char *argv[]) {
  int s, bytes;
  struct sockaddr_in sa;
  struct hostent *he;
  char buf[BUFSIZ+1];
  char *host;

  if ((s = socket(PF_INET, SOCK_STREAM, 0)) < 0) {
    perror("socket");
    return 1;
  }

  memset(&sa, '\0', sizeof(sa));

  sa.sin_family = AF_INET;
  sa.sin_port = htons(13);

  host = (argc > 1) ? argv[1] : "time.nist.gov";

  if ((he = gethostbyname(host)) == NULL) {
    herror(host);
    return 2;
  }

  memcpy(&sa.sin_addr, he->h_addr_list[0], he->h_length);

  if (connect(s, (struct sockaddr *)&sa, sizeof sa) < 0) {
    perror("connect");
    return 3;
  }

  while ((bytes = read(s, buf, BUFSIZ)) > 0)
    write(1, buf, bytes);

  close(s);
  return 0;
}

现在,我们可以在命令行中键入域名(或IP地址,两种方式都可以),程序将尝试连接到其daytime服务器。否则,它仍然默认为time.nist.gov。但是,即使在这种情况下,我们也将使用gethostbyname而不是硬编码192.43.244.18。这样,即使将来其IP地址发生变化,我们仍然可以找到它。

由于从本地服务器获取时间几乎不需要时间,因此您可以连续运行两次daytime:第一次从time.nist.gov获取时间,第二次从您自己的系统获取时间。然后,您可以比较结果并查看系统时钟的准确程度

% daytime ; daytime localhost

52080 01-06-20 04:02:33 50 0 0 390.2 UTC(NIST) *
2001-06-20T04:02:35Z
%

如您所见,我的系统比NIST时间快两秒。

7.6.2. getservbyname

有时您可能不确定某个服务使用什么端口。getservbyname(3)函数,也声明在netdb.h中,在这些情况下非常方便

struct servent * getservbyname(const char *name, const char *proto);

servent结构包含s_port,其中包含正确的端口,已采用网络字节序

如果我们不知道daytime服务的正确端口,我们可以通过这种方式找到它

struct servent *se;
  ...
  if ((se = getservbyname("daytime", "tcp")) == NULL {
    fprintf(stderr, "Cannot determine which port to use.\n");
    return 7;
  }
  sa.sin_port = se->s_port;

您通常知道端口。但是,如果您正在开发新协议,则可能在非官方端口上对其进行测试。总有一天,您将在某个地方(至少在您的/etc/services中,getservbyname就是在这里查找)注册协议及其端口。在上述代码中返回错误而不是返回错误,您只需使用临时端口号。一旦您在/etc/services中列出了该协议,您的软件将找到其端口,而无需您重写代码。

7.7. 并发服务器

与顺序服务器不同,并发服务器必须能够同时为多个客户端提供服务。例如,聊天服务器可能会为特定客户端服务数小时——它不能等到停止为一个客户端服务后再为下一个客户端服务。

这需要对我们的流程图进行重大更改

serv2
图11. 并发服务器

我们将serve守护进程移动到其自己的服务器进程。但是,由于每个子进程都继承所有打开的文件(套接字被视为文件),因此新进程不仅继承了“已接受的句柄”,即accept调用返回的套接字,还继承了顶级套接字,即最开始由顶级进程打开的套接字。

但是,服务器进程不需要此套接字,应该立即将其close。类似地,守护进程不再需要已接受的套接字,不仅应该,而且必须将其close——否则,它迟早会用完可用的文件描述符

服务器进程完成服务后,应关闭已接受的套接字。它现在退出,而不是返回到accept

在UNIX®下,进程不会真正退出。相反,它返回到其父进程。通常,父进程会wait其子进程,并获取返回值。但是,我们的守护进程不能简单地停止并等待。这将违背创建额外进程的全部目的。但如果它从未执行wait,则其子进程将变成僵尸进程——不再起作用,但仍在四处游荡。

因此,守护进程需要在其初始化守护进程阶段设置信号处理程序。至少必须处理SIGCHLD信号,以便守护进程可以从系统中删除僵尸返回值并释放它们占用的系统资源。

这就是为什么我们的流程图现在包含一个处理信号框,该框未连接到任何其他框。顺便说一句,许多服务器还会处理SIGHUP,通常将其解释为来自超级用户的信号,指示它们应该重新读取其配置文件。这使我们能够在不终止和重新启动这些服务器的情况下更改设置。


上次修改于:2024年9月4日,作者 rilysh