第十六章:网络IPC:套接字
16.1、引言
上一章考查了各种Unix系统所提供的经典进程间通信(IPC)机制:管道、先进先出、消息队列、信号量以及共享内存。通过这些机制,同一台计算机上运行的进程可以相互通信。本章将考查不同计算机(通过网络连接)上运行的进程相互通信的机制:网络进程间通信(network IPC)。
16.2、套接字描述
套接字时通端点的抽象。与应用程序要使用文件描述符访问文件一样,访问套接字也需要用套接字描述符。
要创建一个套接字,可以调用socket函数。
#include <sys/socket.h> int socket(int domain, int type, int protocol); // 返回值:若成功则返回文件(套接字)描述符,若出错则返回-1
参数domain(域)确定通信的特性,包括地址格式。
参数type确定套接字的类型,进一步确定通信特征。
参数protocol通常是0,表示按给定的域和套接字类型选择默认的协议。
调用socket与调用open相类似,在两种情况下,均可获得用于输入、输出的文件描述符。当不再需要改文件描述符时,调用close来关闭对文件或套接字的访问。
16.3、寻址
在学习用套接字做一些有意义的事情之前,需要知道如何确定一个目标通信进程。进程的标识有两个部分:计算机的网络地址可以帮助标识网络上想与之通信的计算机,而服务可以帮助标识计算机上的特定的进程。
16.3.1、字节序
运行在同一台计算机上的进程不用考虑字节序问题,字节序是处理器的架构特性。但是当与其他计算机通信时,就不得不考虑字节序问题。
网络协议用了指定字节序的方案来处理异构计算机之间的通信时的字节序问题。TCP/IP协议栈采用大端字节序。
对于TCP/IP程序,提供了四个通用函数以实现在处理器字节序和网络字节序之间的转换。
#include <arpa/inet.h> uint32_t htonl(uint32_t hostint32); // 返回值:以网络字节序表示的32位整型数 uint16_t htons(uint16_t hostint16); // 返回值:以网络字节序表示的16位整型数 uint32_t ntohl(uint32_t netint32); // 返回值:以主机字节序表示的32位整型数 uint16_t ntohs(uint16_t netint16); // 返回值:以主机字节序表示的16位整型数
其中:h表示"主机(host)"字节序,n表示"网络(network)"字节序。l表示"长(long)"整数(即4个字节),s表示"短(short)"整数(即2个字节)。这4个函数定义在<arpa/inet.h>中,也有比较老的系统将其定义在<netinet/in.h>中。
16.3.2、地址格式
地址标识了特定通信域中的套接字端点,地址格式与特定的通信域相关。为使不同格式地址能够被传入到套接字函数,地址被强制转换成通用的地址结构sockaddr表示:
struct sockaddr { sa_family sa_family; /* address family */ char sz_data[]; /* variable-length address */ . . . }
套接字实现可以自由的添加额外的成员并且定义sa_data成员的大小。例如在LINUX中,该结构定义如下:
struct sockaddr { sa_family_t sa_family; /* address family */ char sa_data[14]; /* variable-length address */ };
而在FreeBSD中,该结构定义如下:
struct sockaddr { unsigned char sa_len; /* total lenght */ sa_family_t sa_family; /* address family */ char sa_data[14]; /* variable-length address */ };
因特网地址定义在<netinet/in.h>中。在IPv4因特网域(AF_INET)中,套接字地址用如下结构sockaddr_in表示:
struct in_addr { in_addr_t s_addr; /* IPv4 address */ }; struct sockaddr_in { sa_family_t sin_family; /* address family */ in_port = sin_port; /* portnumber */ struct in_addr sin_addr; /* IPv4 address */ };
数据类型in_port_t定义成uint16_t。数据类型in_addr_t定义成uint32_t。这些整数类型在<stdint.h>中定义并制定了相应的位数。
与IPv4因特网域(AF_INET)相比较,IPv6因特网域(AF_INET6)套接字地址用如下结构sockaddr_in6表示:
struct in6_addr { uint8_t s6_addr[16]; /* IPv6 address */ }; struct sockaddr_in6 { sa_family sin6_family; /* address family */ in_port_t sin6_port; /* port number */ uint32_t sin6_flowinfo; /* traffic class and flow info */ struct in6_addr sin6_addr; /* IPv6 address */ uint32_t sin6_scope_id; /* set of interface for scope */ };
有时,需要打印出能被人而不是计算机所理解的地址格式。BSD网络软件中包含了函数inet_addr和inet_ntoa,用于在二进制地址格式与点分十进制字符串表示(a.b.c.d)之间相互转换。这些函数仅用于IPv4地址,但功能相似的两个新函数inet_ntop和inet_pton支持IPv4和IPv6地址。
#include <arpa/inet.h> const char* inet_ntop(int domain, const void *restirct addr, char *restirct str, socklen_t size); // 返回值:若成功则返回地址字符串指针,若出错则返回NULL int inet_pton(int domain, const char *restirct str, void *restirct addr); // 返回值:若成功则返回1,若格式无效则返回0,若出错则返回-1
16.3.3、地址查询
通过调用gethostent,可以找到给定计算机的主机信息。
#include <netdb.h> struct hostent* gethostent(void); // 返回值:若成功则返回指针,若出错则返回NULL void sethostent(int stayopen); void endhostent(void);
如果主机数据文件没有打开,gethostent会打开它。函数gethostent返回文件的下一个条目。函数sethostent会打开文件,如果文件已经被打开,那么将其回绕。函数endhostent将关闭文件。
当gethostent返回时,得到一个指向hostent结构的指针,该结构可能包含一个静态的数据缓冲区。每次调用gethostent将会覆盖这个缓冲区。数据结构hostent至少包含如下成员:
struct hostent { char *h_name; /* name of host */ char **h_aliases; /* pointer to alternate host name array */ int h_addrtype; /* address type */ int h_length; /* length in bytes of address */ char **h_addr_list; /* pointer to array of network address */ . . . };
返回的地址采用网络字节序表示。
两个附加函数gethostbyname和gethostbyaddr,原来包含在hostent函数里面,现在被认为是过时的,马上将会看到其替代函数。
能够采用一套相似的接口来获得网络名字和网络号。
#include <netdb.h> struct netent *getnetbyaddr(uint32_t net, int type); struct netnet *getnetbyname(const char *name); struct netent *getnetent(void); // 以上三个函数的返回值:若成功则返回指针,若出错则返回NULL void setnetent(int stayopen); void endnetent(void);
结构netent至少包含如下字段:
struct netent { char *n_name; /* network name */ char **n_aliases; /* alternate network name array pointer */ int n_addrtype; /* addres type */ uint32_t n_net; /* network nuber */ . . . };
网络号按照网络字节序返回。地址类型是一个地址族常量(AF_INET)
可以将协议名字和协议号采用以下函数映射:
#include <netdb.h> struct protoent* getprotobyname(const char *name); struct protoent *getprotobynumber(int proto); struct ptotoent *getprotoent(void); // 以上所有函数的返回值:若成功则返回指针,若出错则返回NULL void setprotoent(int stayopen); void endprotoent(void);
结构protoent至少包含:
struct protoent { char *p_name; /* protocol name */ char **p_aliases; /* pointer to alternate protocol name array */ int p_proto; /* protocol number */ . . . };
服务是有地址的端口号部分表示的。每个服务由一个唯一的、熟知的端口号来提供。采用函数getservbyname可以将一个服务名字映射到一个端口号,函数getservbyport将一个端口号映射到一个服务名,或者采用函数getservent顺序扫描服务数据库。
#include <netdb.h> struct servent *getservbyname(const char *name, const char *proto); struct servent *getservbyport(int port, const char *proto); struct servent *getservent(void); // 以上所有函数的返回值:若成功则返回指针,若出错则返回NULL void setservent(int stayopen); void endservent(void);
POSIX.1定义了若干新的函数,允许应用程序将一个主机名字和服务名字映射到一个地址,或者相反。这些函数代替老的函数gethostbyname和gethostbyaddr。
函数getaddrinfo允许将一个主机名字和服务名字映射到一个地址。
需要提供主机名字、服务名字,或者两者都提供。如果仅仅提供一个名字,另外一个必须是一个空指针。主机名字可以是一个节点名或点分十进制表示的主机地址。
函数getaddrinfo返回一个结构addrinfo的链表。可以用freeaddrinfo来释放一个或多个这种结构,这取决于用ai_next字段链接起来的结构有多少。
结构addrinfo的定义如下:
struct addrinfo { int ai_flags; /* customize behavior */ int ai_family; /* address family */ int ai_socktype; /* socket type */ int ai_protocol; /* protocol */ socklen_t ai_addrlen; /* length in bytes of address */ struct sockaddr *ai_addr; /* address */ char *ai_canonname; /* canonical name of host */ struct addrinfo *ai_next; /* next in list */ . . . };
如果getaddrinfo失败,不能使用perror或strerror来生成错误消息。替代的,调用gai_strerror将返回的错误码转换成错误消息。
#include <netdb.h> const char* gai_strerror(int error); // 返回值:指向描述错误的字符串的指针
函数getnameinfo将地址转换成主机名或者服务名
#include <sys/socket.h> #include <netdb.h> int getnameinfo(const struct sockaddr *restirct addr, socklen_t alen, char *restirct host, socklen_t hostlen, char *restrict service, socklen_t servlen, unsigned int flags); // 返回值:若成功则返回0,若出错则返回非0错误码
16.3.4、将套接字与地址绑定
与客户端的套接字关联的地址没有太大的意义,可以让系统选一个默认地址。然而,对于服务器,需要给一个接受客户端请求的套接字绑定一个众所周知的地址。
可以用bind函数将地址绑定到一个套接字。
#include <sys/socket.h> int bind(int sockfd, const struct sockaddr *addr, socklen_t len); // 返回值:若成功则返回0,若出错则返回-1
对于因特网域,如果指定IP地址为INADDR_ANY,套接字端点可以被绑定到所有的系统网络接口。这意味着可以接收到这个系统所安装的所有网卡的数据包。在下一节中将看到,如果调用connect或listen,但没有绑定地址到一个套接字,系统会选一个地址并将其绑定到套接字。
可以调用函数getsockname来发现绑定到一个套接字的地址。
#include <sys/socket.h> int getsockname(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict alenp); // 返回值:若成功则返回0,若出错则返回-1
如果套接字已经和对方连接,调用getpeername来找到对方的地址。
#include <sys/socket.h> int getpeername(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict alenp); // 返回值:若成功则返回0,若出错则返回-1
16.4、建立连接
如果处理的是面向连接的网络服务,在开始交换数据以前,需要在请求服务的进程套接字(客户端)和提供服务的进程套接字(服务器)之间建立一个连接。可以用connect建立一个连接。
#include <sys/socket.h> int connect(int sockfd, const struct sockaddr *addr, socklen_t len); // 返回值:若成功则返回0,若出错则返回-1
在connect中所指定的地址是想与之通信的服务器地址。如果sockfd没有绑定到一个地址,connect会给调用者绑定一个默认地址(也就是说客户端的socket也是会绑定的,只不过不需要我们手动去绑定)。
当连接一个服务器时,处于一些原因,连接可能失败。要连接的机器必须开启并且正在运行,服务器必须绑定到一个想与之连接的地址,并且在服务器的等待连接队列中应有足够的空间。因此应用程序必须能够处理connect返回的错误,这些错误可能有一些瞬间的变化条件引起的。(最常见的处理方法就是等待重连)
如果套接字描述符处于将要在16.8节讨论的非阻塞模式下,那么再连接不能马上建立时,connect将会返回-1,并且将errno设为特殊的错误码EINPROGRESS。应用程序可以使用poll或者select来判断文件描述符何时可写。如果可写,连接完成。
服务器调用listen来宣告可以接收连接请求。
#include <sys/socket.h> int listen(int sockfd, int backlog); // 返回值:若成功则返回0,若出错则返回-1
参数backlog提供了一个提示,用于表示该进程所要入队的连接请求数量。其实际值由系统决定,但上限有<sys/socket.h>中SOMAXCONN指定。
一旦服务器调用了listen,套接字就能接收连接请求。使用函数accept获得连接请求并建立连接。
#include <sys/socket.h> int accept(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict len); // 返回值:若成功则返回文件(套接字)描述符,若出错则返回-1
函数accept所返回的文件描述符时套接字描述符,该描述符连接到调用connect函数的客户端。而传给accept的原套接字继续保持可用状态并接收其他连接请求。
如果没有连接请求等待处理,accept会阻塞知道一个请求到来。如果sockfd处于非阻塞模式,accept会返回-1并将errno设置为EAGAIN或EWOULDBLOCK。
16.5、数据传输
有三个函数用来发送数据,三个用于接手数据。首先,考查用于发送数据的函数。
最简单的是send,它和write很像,但是可以指定标志来改变处理传输数据的方式。
#include <sys/socket.h> ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags); // 返回值:若成功则返回发送的字节数,若出错则返回-1
如果send成功返回,并不必然表示连接另一端的进程接收数据。所保证的仅是当send成功返回时,数据已经无错误地发送到网络上。
函数sendto和send很类似,区别在于sendto允许在无连接的套接字上指定一个目标地址。
#include <sys/socket.h> ssize_t sendto(int sockfd, const void *buf, size_t nbytes, int flags, const struct sockaddr *destaddr, socklen_t destlen); // 返回值:若成功则返回发送的字节数,若出错则返回-1
可以使用不止一个的选择来通过套接字发送数据。可以调用带有msghdr结构的sendmsg来指定多重缓冲区传输数据,这和writev很相像(14.7节)。
#include <sys/socket.h> ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags); // 返回值:若成功则返回发送的字节数,若出错则返回-1
POSIX.1定义了msghdr结构,它至少有如下成员:
struct msghdr { void *msg_name; /* optional address */ socklen_t msg_namelen; /* address size in bytes */ struct iovec *msg_iov; /* array of I/O buffers */ int msg_iovlen; /* number of elements in array */ void *msg_control; /* ancillary data */ socklen_t msg_controllen; /* number of ancillary bytes */ int msg_flags; /* flags fro received message */ . . . };
函数recv和read很像,但是允许指定选项来控制如何接受数据
#include <sys/socket.h> ssize_t recv(int sockfd, void *buf, size_t nbytes, int flags); // 返回值:以字节计数的消息长度,若无可用消息或对方已经按序结束则返回0,若出错则返回-1
如果有兴趣定位发送者位置,可以使用recvfrom获取发送者的源地址:
#include <sys/socket.h> ssize_t recvfrom(int sockfd, void *restrict buf, size_t len, int flags, struct sockaddr *restrict addr, socklen_t *restrict addrlen); // 返回值:以字节计数的消息长度,若无可用消息或对方已经按序结束则返回0,若出错则返回-1
为了将接收到的数据送到多个缓冲区,或者想接受辅助数据,可以使用recvmsg。
#include <sys/socket.h> ssize_t recvmsg(int sockfd, struct msghdr *msg, int flag); // 返回值:以字节计数的消息长度,若无可用消息或对方已经按序结束则返回0,若出错则返回-1
msghdr被用来指定可以接收数据的输入缓冲区,可以设置flags参数来改变recvmsg的默认行为。
实例:
16.6、套接字选项
套接字机制提供两个套接字接口来控制套接字选项的行为,一个接口用来设置选项,另一个接口允许查询选项的状态。可以获取或设置三种选项:
1.通过选项,工作在所有套接字上。
2.在套接字层次管理的选项,但是依赖下层协议的支持。
3.特定于某个协议的选项,为每个协议所特有。
可以采用setsockopt函数来设置套接字选项:
#include <sys/socket.h> int setsockopt(int sockfd, int level, int option, const void *val, socklen_t len); // 返回值:若成功则返回0,若出错则返回-1
参数level标识了选项应用的协议,如果是通用的套接字层协议,则设置为SOL_SOCKET,否则,level设置成控制这个选项的协议号。例如TCP选项,是IPPROTO_TCP,IP选项,是IPPROTO_IP。下图总结了通用套接字层的选项。
可以使用getsockopt来查询选项的当前值:
#include <sys/socket.h> int getsockopt(int sockfd, int level, int option, void *restrict val, socklen_t *restrict lenp); // 返回值:若成功则返回0,若出错则返回-1
16.7、带外数据
带外数据是一些通信协议所支持的可选特征,允许更高级的数据比普通数据优先传输。即使传输队列已经有数据,带外数据先行传输。TCP支持带外数据,但是UDP不支持。套接字接口对带外数据的支持,很大程度上受TCP带外数据具体实现的影响。
TCP将带外数据称为“紧急”数据(“urgent” Data)。TCP仅支持一个字节的紧急数据,但是允许紧急数据在普通数据传递机制数据流之外传输。为了产生紧急数据,在三个send函数中任何一个指定标志MSG_OOB。如果带MSG_OOB标志传输字节超过一个时,最后一个字节被看作紧急数据字节。
TCP支持紧急标记(urgent mark)的概念:在普通数据流中紧急数据所在的位置。如果采用套接字选项SO_OOBINLINE,那么可以在普通数据中接收紧急数据。为帮助判断是否接收到紧急标记,可以使用函数sockatmark。
#include <sys/socket.h> int sockatmark(int sockfd); // 返回值:若在标记处则返回1,若没有在标记处则返回0,若出错则返回-1
当下一个要读的字节在紧急标志所标识的位置时,sockatmark返回1.
16.8、非阻塞和异步I/O
通常,recv没有数据可用时会阻塞等待。同样的,当套接字输出队列没有足够空间发出消息时,send函数也会阻塞。在套接字非阻塞模式下,这种情况会改变,在这种情况下,这些函数不会阻塞而是失败,设置errno为EWOULDBLOCK或者EAGAIN。当这些发生时,可以用select或poll来判断合适能接收或者传输数据。
16.9、小结
在本章中,考查了IPC机制,这种机制允许一个进程与另外一个进程通信,无论是不同的机器上还是同一机器中。讨论了套接字端点如何命名,在连接服务器时,如何发现所要用的地址。
我们给出了采用无连接的套接字(例如,基于数据报)和面向连接套接字的客户端和服务器的例子。简要讨论了异步和非阻塞的套接字I/O,以及用于管理套接字选项的接口。
在下一章,将会考查一些高级IPC主题,包括在同一台机器上如何使用套接字传送文件描述符。