第十五章 套接字

套接字接口(socket interface)是一种通信工具。一台机器上的进程可以使用套接字和另外一台机器上的进程通信,这样就可以支持分布在网络中的客户/服务器系统。同一台机器上的进程之间也可以使用套接字进行通信。

什么是套接字

套接字是一种通信机制,它明确地将客户和服务器区分开来。套接字机制可以实现多个客户端连接到一个服务器。

套接字连接

首先,服务器应用程序用系统调用socket来创建一个套接字,它是类似文件描述符的资源,不能与其他进程共享。

接下来,服务器进程会给套接字起个名字。本地套接字的名字是Linux文件系统中的文件名,一般放在/tmp或/usr/tmp目录中。对于网络套接字,它的名字是与网络有关的标识符(比如端口号)。这个标识符允许Linux将进入的针对特定端口号的连接转到正确的服务器进程。

我们用系统调用bind来给套接字命名。然后服务器进程就开始等待客户端连接到这个命名套接字。

系统调用listen的作用是,创建一个队列并将其用于存放来自客户的进入连接。服务器通过系统调用accept来接受客户的连接。

服务器调用accept时,它会创建一个新套接字,这个新套接字只用于与这个特定的客户进行通信。而命名套接字则被保留下来继续处理来自其他客户的连接。

基于套接字系统的客户端首先调用socket创建一个未命名套接字,然后将服务器的命名套接字作为一个地址,调用connect与服务器建立连接。

一旦连接建立,我们就可以开始双向的数据通信。

一个简单的案例见:client1.c, server1.c

套接字属性

套接字的特性由3个属性确定:域(domain)、类型(type)和协议(protocol)。套接字还用地址作为它的名字。

套接字的域

域指定套接字通信中使用的网络介质。最常见的套接字域是AF_INET(adress format internet),它指的是Internet网络,并使用IP地址来指定网络中的计算机。

所有的IP地址都用4个数字来表示,每个数字都小于256,即所谓的点分四元组表示法(dotted quad)。

当客户使用套接字进行跨网络的连接时,它就需要用到服务器计算机的IP地址。客户可以通过IP端口来指定一台联网机器上的某个特定服务。端口通过分配一个唯一的16位整数来标识。套接字必须在开始通信之前绑定一个端口。

书中第一个例子的域是UNIX文件系统域AF_UNIX,它的地址就是文件名。还有一些其他的域,但不在讨论范围。

套接字类型

因特网协议提供了了两种通信机制:流(stream)和数据报(datagram)。

流套接字

流套接字提供的是一个有序、可靠、双向字节流的连接。

流套接字由类型SOCK_STREAM指定,它们是在AF_INET域中通过TCP/IP连接实现的。

数据报套接字

由类型SOCK_DGRAM指定,它不建立和维持一个连接。它对可以发送的数据报的长度有限制。数据报作为一个单独的网络消息被传输,它可能会丢失、复制或乱序到达。

数据报套接字是在AF_INET域中通过UDP/IP连接实现的,它提供的是一种无序的不可靠服务。相对来说它们开销比较小,速度也很快。

套接字协议

UNIX网络套接字和文件系统套接字不需要选择一个特定的协议,只需要使用其默认值即可。

创建套接字

socket系统调用创建一个套接字并返回一个描述符,该描述符可以用来访问该套接字。

#include <sys/types.h>
#include <sys/socket.h>

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

创建的套接字是一条通信线路的一个端点。domain参数最常用的取值是AF_UNIX和AF_INET。type的取值可以是SOCK_STREAM和SOCK_DGRAM。protocol通常不需要选择,本章节中都是用0,表示使用默认协议。

套接字地址

每个套接字域都有自己的地址格式。对于AF_UNIX域套接字来说,它的地址由结构sockaddr_un来描述:

#include <sys/un.h>

struct sockaddr_un {
    sa_family_t    sun_family;    /* AF_UNIX */
    char           sun_path[];    /* pathname */
};

对套接字进行处理的系统调用可能需要接受不同类型的地址,每种地址格式都使用一种类似的结构来描述,它们都以一个指定地址类型(套接字域)的成员开始。

在AF_INET域中,套接字地址由结构sockaddr_in来指定:

#include <netinet/in.h>

struct sockaddr_in {
    short int          sin_family;    /* AF_INET */
    unsigned short int sin_port;      /* Port number */
    struct in_addr     sin_addr;      /* Internet address */
};

结构in_addr是IP地址:

struct in_addr {
    unsigned long int s_addr;
};

IP地址中的4个字节组成一个32位的值。一个AF_INET套接字由它的域、IP地址和端口号完全确定。

命名套接字

想要套接字被使用,服务器程序就必须给该套接字命名。这样,AF_UNIX套接字就会关联到一个文件系统的路径名。AF_INET套接字就会关联到一个IP端口号。

#include <sys/socket.h>

int bind(int socket, const struct sockaddr *address, size_t address_len);

bind系统调用把参数address中的地址分配给与socket描述符关联的未命名套接字。地址结构的长度由参数address_len传递。

bind函数调用成功返回0,失败返回-1并设置errno。可取值见书本。

创建套接字队列

为了能够在套接字上接受进入的连接,服务器程序必须创建一个队列来保存未处理的请求。

#include <sys/socket.h>

int listen(int socket, int backlog);

listen函数将队列长度设置为backlog参数的值,等待处理的进入连接的个数最多不能超过这个数字。再往后的连接将被拒绝,导致客户的连接请求失败。backog参数常用的值是5。

listen函数成功返回0,失败返回-1并设置errno。见书本。

接受连接

通过accept系统调用来等待客户建立对该套接字的连接。

#include <sys/socket.h>

int accept(int socket, struct sockaddr *address, size_t *address_len);

accept系统调用只有当客户程序试图连接到由socket参数指定的套接字上时才会返回。这里的客户指的是在套接字队列中排在第一个的未处理的连接。accept函数将创建一个新套接字来与该客户进行通信,并且返回新套接字的描述符。

连接客户的地址将被放入address参数指向的sockaddr结构中,如果不关心这个地址,可以设置为空指针。

参数address_len指定客户结构的长度,如果客户地址的长度超过这个值,它将被截断。这个调用返回时,address_len将被设置为连接客户地址结构的实际长度。

如果套接字队列中没有未处理的连接,accept将阻塞直到有客户建立连接为止。可以通过对套接字描述符设置O_NONBLOCK标志来改变这一行为:

int flags = fcntl(sock, F_GETFL, 0);
fcntl(socket, F_SETFL, O_NONBLOCK|flags);

如果发生错误,accept函数将返回-1。

请求连接

客户程序通过在一个未命名套接字和服务器监听套接字之间建立连接的方法来连接到服务器。

#include <sys/socket.h>

int connect(int socket, const struct sockaddr *address, size_t address_len);

参数socket是通过socket调用获得的一个有效的描述符,其指定的套接字将连接到参数address指定的服务器套接字,address指向的结构的长度由参数address_len指定。

成功时,connect调用返回0,失败时返回-1,并设置errno。见书本。

如果连接不能立刻建立,connect调用将阻塞一段不确定的超时时间,一旦超时时间到达,连接将被放弃,connect调用失败。

connect调用的阻塞特性可以通过设置该文件描述符的O_NONBLOCK标志来改变,此时如果连接不能立刻建立,connect将失败并把errno设置为EINPROGRESS,而连接将以异步方式继续进行。可以用select调用来检查套接字是否已处于写就绪状态。

关闭套接字

你可以通过调用close函数来终止服务器和客户上的套接字连接。

对于服务器来说,应该在read调用返回0时关闭套接字,但如果套接字是一个面向连接类型的,并设置了SOCK_LINGER选项,close调用会在该套接字还有未传输数据时阻塞。

套接字通讯

我们不能使用小于1024的端口号,因为它们是为系统使用保留的。其它的端口号信息通常列在系统文件/etc/services中。编写基于套接字的应用程序时,请注意总要选择没有列在该配置文件中的端口号。

我们将在局域网中运行客户和服务器。UNIX计算机通常会配置一个回路网络,它只包含一台计算机(自身),传统上它被称为localhost,它有一个标准的IP地址127.0.0.1。这就是本地主机。

见代码案例:example2,但这个案例有一个故意设置的错误(没有考虑主机序和网络序)。

整数 INADDR_ANY 可以表示将接受来自计算机任何网络接口的连接。

主机字节序和网络字节序

使用netstat命令可以查看网络连接情况,包括连接对应的服务器和客户的端口号。

如上例,显示的服务器套接字的端口号是错误的,这是因为没有把主机字节序转换成网络字节序(不同计算机的主机字节序可能不同,为了保证不同计算机的一致性,所以要转换成统一的网络字节序)。

#include <netinet/in.h>

uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);

这些函数将16位和32位整数在主机字节序和标准的网络字节序之间进行转换。函数名是与之对应的转换操作的简写形式。例如“host to network, long”(htonl,长整数从主机字节序到网络字节序的转换)。

如果计算机的主机字节序与网络字节序相同,这些函数的内容实际上就是空操作。

为了保证有正确的字节序,服务器和客户需要使用这些函数来进行转换:

server_address.sin_addr.s_addr = htonl(INADDR_ANY);
server_address.sin_port = htons(9734);

但不需要对函数调用inet_addr("127.0.0.1")进行转换,因为inet_addr已经被定义为产生一个网络字节序的结果。

见example3。

多客户

考虑有多个客户同时连接一个服务器的情况。服务器程序在接受来自客户的一个新连接时,会创建出一个新的套接字,而原先的监听套接字将被保留以继续监听以后的连接。如果服务器不能立刻接受后来的连接,它们将被放到队列中以等待处理。

原先的套接字仍然可用并且套接字的行为就像文件描述符,这一事实给我们提供了一种同时服务多个客户的方法。如果服务器调用fork为自己创建第二份副本,打开的新套接字就将被新的子进程所继承。新的子进程可以和连接的客户进行通信,而主服务器进程可以继续接受以后的客户连接。

由于我们创建子进程,但并不等待它们完成,所以必须安排服务器忽略SIGCHLD信号以避免出现僵尸进程。

个人理解:这里所说的方法即是:每当接受(accept)队列中一个连接后,就fork一个子进程,由子进程使用新获得的sockfd和客户通信。而父进程继续处理以后的客户连接。

事例代码见example4。

select系统调用

select系统调用允许程序同时在多个底层文件描述符上等待输入的到达(或输出的完成)。这意味着终端仿真程序可以一直阻塞到有事情可做为止。类似地,服务器也可以通过同时在多个打开的套接字上等待请求到来的方法来处理多个客户。

select函数对数据结构fd_set进行操作,它是由打开的文件描述符构成的集合。有一组定义好的宏可以用来控制这些集合:

#include <sys/time.h>
#include <sys/types.h>

void FD_CLR(int fd, fd_set *set);
int  FD_ISSET(int fd, fd_set *set);
void FD_SET(int fd, fd_set *set);
void FD_ZERO(fd_set *set);

FD_ZERO用于将fd_set初始化为空集合,FD_SET和FD_CLR分别用于在集合中设置和清除由参数fd传递的文件描述符。如果FD_ISSET宏中由参数fd指向的文件描述符是由参数fdset指向的fd_set集合中的一个元素,FD_ISSET将返回非零值。

fd_set结构中可以容纳的文件描述符的最大数目由常量FD_SETSIZE指定。

select函数用一个超时值来防止无限期的阻塞,这个超时值由一个timeval结构给出。

struct timeval {
    long    tv_sec;         /* seconds */
    long    tv_usec;        /* microseconds */
};

select系统调用的原型如下:

#include <sys/time.h>
#include <sys/types.h>

int select(int nfds, fd_set *readfds, fd_set *writefds,
                  fd_set *exceptfds, struct timeval *timeout);

select调用用于测试文件描述符集合中,是否有一个文件描述符已处于可读状态或可写状态或错误状态,它将阻塞以等待某个文件描述符进入这些状态。

参数nfds指定需要测试的文件描述符数目,测试的描述符范围从0到nfds-1。3个描述符集合都可以被设置为空指针,这表示不执行相应的测试。

select函数会在发生以下情况时返回:readfds中有描述符可读、writefds中有描述符可写或errorfds中有描述符遇到错误条件。如果这3种情况都没有发生,select将在timeout指定的超时时间经过后返回,如果timeout是一个空指针,这个调用会一直阻塞下去。

当select返回时,描述符集合将被修改以指示哪些描述符正处于可读、可写或有错误的状态。可以使用FD_ISSET对描述符进行测试,来找出需要注意的描述符。

select调用返回状态发生变化的描述符总数。失败时它将返回-1并设置errno。timeout参数指向的结构可能还会被修改为剩余的超时时间。

见代码案例:select.c。

多客户

通过使用select调用来同时处理多个客户就无需依赖于子进程了。但在把这个技巧应用到实际的应用程序中时,必须要注意,不能在处理第一个连接的客户时让其他客户等太长的时间。

服务器可以使用select调用同时检查监听套接字和客户连接的套接字。一旦select调用指示有活动发生,就可以调用FD_ISSET来遍历所有可能的文件描述符,以检查是哪个上面有活动发生。

如果是监听套接字可读,说明正有一个客户试图建立连接,此时就可以调用accept而不必担心其阻塞。如果是客户描述符准备好,说明有一个客户请求需要我们读取和处理。

见代码案例:example5。

数据报

当客户需要发送一个短小的查询请求给服务器,并且期望接收到一个短小的响应时,我们一般就使用由UDP服务提供的服务。

sendto系统调用从buffer缓冲区中给使用指定套接字地址的目标服务器发送一个数据报。它的原型如下所示:

int sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);

在正常应用中,flag参数一般被设置为0。

recvfrom系统调用在套接字上等待从特定地址到来的数据报,并将它放入buffer缓冲区。它的原型如下所示:

ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);

同样,在正常应用中,flag参数一般被设置为0。

当错误发生时,sendto和recvfrom都将返回-1并设置errno。

除非用fcntl将套接字设置为非阻塞方式,否则recvfrom调用将一直阻塞。我们可以用与前面的面向连接服务器一样的方式,通过select调用和超时设置来判断是否有数据到达套接字。