进程间通信(10) - 网络套接字(socket)[1]

时间:2021-12-05 03:56:24

1.前言

本篇文章的所有例子,基于RHEL6.5平台(linux kernal: 2.6.32-431.el6.i686)。

2.网络中进程间通信

本地的进程间通信(IPC)方式有很多种,总结起来,大概可以分为下面的这4类:
  a).消息传递。包括管道(点此链接),FIFO(点此链接),消息队列(点此链接)等。
  b).共享内存。包括匿名和具名的(点此链接)。
  c).同步。包括互斥量,条件变量,读写锁,文件和记录锁,信号量等。
  d).远程过程调用。例如Sun RPC。
但是,这些通信方式,大部分都局限于本地。如何在网络中进程之间通信呢?首先需要解决的问题是如何唯一的标识一个进程。在本地可以通过进程PID来唯一标识进程,但是在网络中仅依靠这个是不行的。现实情况中,TCP/IP协议族已经解决了这个问题,网络层的"IP地址"可以唯一标识网络中的主机,而传输层的"协议+端口"可以唯一标识主机中的应用程序(即进程)。这样,利用这个三元组(ip, 协议, 端口)就可以标识网络的进程了,网络中进程通信就可以利用这个标志与其它进程进行交互。

3.socket介绍

socket起源于Unix,而Unix/Linux基本哲学之一就是"一切皆文件“,都可以用"打开open->读写write/read->关闭close"模式来操作。socket就可以看作是这种模式的一个实现,socket相关的函数就是对其进行的操作(读写I/O,打开,关闭)。这些函数会在后面章节中介绍。

socket一词的起源:
在组网领域的首次使用是在1970年2月12日发布的文献IETF RFC33中发现的,撰写者为Stephen Carr、Steve Crocker和Vint Cerf。根据美国计算机历史博物馆的记载,Croker写道:“命名空间的元素都可称为套接字接口。一个套接字接口构成一个连接的一端,而一个连接可完全由一对套接字接口规定。”计算机历史博物馆补充道:“这比BSD的套接字接口定义早了大约12年。”
前面已经提到socket也可以认为是一种文件操作,符合"open->write/read->close”模式,则socket应该提供了这些操作对应的函数接口。下面已TCP为例,介绍常用的sockt接口函数。

4.socket()函数

#include <sys/types.h>
#include <sys/socket.h>
int socket(int domain, int type, int protocol);
socket函数对应于普通文件的打开操作。普通文件的打开操作返回一个文件描述字,而socket()用于创建一个socket描述符(socket descriptor),它唯一标识一个socket。这个socket描述字跟文件描述字一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。
正如可以给fopen的传入不同参数值,以打开不同的文件。创建socket的时候,也可以指定不同的参数创建不同的socket描述符,socket函数的三个参数分别为:

    · domain:即协议域,又称为协议族(family)。常用的协议族有,AF_INET、AF_INET6、AF_LOCAL(或称AF_UNIX,Unix域socket)、AF_ROUTE等等。协议族决定了socket的地址类型,在通信中必须采用对应的地址,如AF_INET决定了要用IPv4地址(32位的)与端口号(16位的)的组合、AF_UNIX决定了要用一个绝对路径名作为地址。

    · type:指定socket类型。常用的socket类型有,SOCK_STREAM、SOCK_DGRAM、SOCK_RAW、SOCK_PACKET、SOCK_SEQPACKET、SOCK_RDM等等。

    · protocol:顾名思义,就是指定协议。常用的协议有,IPPROTO_TCP、IPPTOTO_UDP、IPPROTO_SCTP、IPPROTO_TIPC等,它们分别对应TCP传输协议、UDP传输协议、STCP传输协议、TIPC传输协议。

注意:上面的type和protocol并不是可以随意组合的,如SOCK_STREAM不可以跟IPPROTO_UDP组合。当protocol为0时,会自动选择type类型对应的默认协议。当我们调用socket创建一个socket时,返回的socket描述符存在于协议族(address family,AF_XXX)空间中,但没有一个具体的地址。如果想要给它赋值一个地址,就必须调用bind()函数,否则就当调用connect()、listen()时系统会自动随机分配一个端口。

5.bind()函数

正如上面所说bind()函数把一个地址族中的特定地址赋给socket。例如对应AF_INET、AF_INET6就是把一个ipv4或ipv6地址和端口号组合赋给socket。

#include <sys/types.h>
#include <sys/socket.h>
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

参数sockfd:
    即socket描述符,它是通过socket()函数创建,唯一标识一个socket。bind()函数就是将给这个描述符绑定一个名字。
参数addr:
    一个const struct sockaddr *指针,指向要绑定给sockfd的协议地址。这个地址结构根据地址创建socket时的地址协议族的不同而不同。具体可以参考4.3这个章节。
参数addrlen
    对应的是地址的长度。

通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。

6.struct sockaddr详解

6.1.sockaddr定义

在linux缓解下,结构体sockaddr定义在/usr/include/linux/socket.h中,共计16字节。定义如下:

typedef unsigned short sa_family_t;
struct sockaddr {
sa_family_t sa_family; /* address family, AF_xxx */ //2字节
char sa_data[14]; /* 14 bytes of protocol address */ //14字节
}

6.2.sockaddr_in定义

在linux环境下,结构体struct sockaddr_in在/usr/include/netinet/in.h中定义,对应的是IPv4地址。具体定义如下:
/* Structure describing an Internet socket address.  */
struct sockaddr_in
{
__SOCKADDR_COMMON (sin_); //2字节
in_port_t sin_port; /* Port number. */ //2字节
struct in_addr sin_addr; /* Internet address. */ //4字节

/* Pad to size of `struct sockaddr'. */ //8字节
unsigned char sin_zero[sizeof (struct sockaddr) -
__SOCKADDR_COMMON_SIZE -
sizeof (in_port_t) -
sizeof (struct in_addr)];
//上面的字符数组sin_zero[8]的存在是为了保证结构体struct sockaddr_in的大小和结构体struct sockaddr的大小相等
};
其中in_port_t类型的具体定位为:
/* Type to represent a port. */
typedef uint16_t in_port_t;
而struct in_addr其实就是32位IP地址。
/* Internet address.  */
typedef uint32_t in_addr_t;
struct in_addr
{
in_addr_t s_addr; /* address in network byte order */
};

6.3.sockaddr与sockaddr_in的联系

struct sockaddr是通用的套接字地址,而struct sockaddr_in则是internet环境下套接字的地址形式,二者长度一样,都是16个字节。二者是并列结构,指向sockaddr_in结构的指针也可以指向sockaddr。一般情况下,需要把sockaddr_in结构强制转换成sockaddr结构再传入系统调用函数中。
下面是一个比较通用的使用sockaddr的例子:

int sockfd;
struct sockaddr_in my_addr;
sockfd = socket(AF_INET, SOCK_STREAM, 0);

my_addr.sin_family = AF_INET; /* 主机字节序 */
my_addr.sin_port = htons(MYPORT); /* short, 网络字节序 */
my_addr.sin_addr.s_addr = inet_addr("192.168.0.1");

bzero(&(my_addr.sin_zero), 8); /* zero the rest of the struct */
//memset(&my_addr.sin_zero, 0, 8);

//强制转换sockaddr_in为sockaddr
bind(sockfd, (struct sockaddr *)&my_addr, sizeof(struct sockaddr));

6.4.sockaddr_in for IPv6

IPv6地址对应的sockaddr_in结构体定义如下:
/* Ditto, for IPv6.  */
struct sockaddr_in6
{
__SOCKADDR_COMMON (sin6_); //2字节
in_port_t sin6_port; /* Transport layer port # */ //2字节
uint32_t sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* IPv6 scope-id */
};
其中,struct in6_addr定义如下:
struct in6_addr { 
unsigned char s6_addr[16]; /* IPv6 address */
};

6.5.unix域对应sockaddr

#define UNIX_PATH_MAX 108
struct sockaddr_un {
sa_family_t sun_family; /* AF_UNIX */
char sun_path[UNIX_PATH_MAX]; /* pathname */
};

7.字节序

主机字节序就是我们平常说的大端和小端模式:不同的CPU有不同的字节序类型,这些字节序是指整数在内存中保存的顺序,这个叫做主机序。引用标准的Big-Endian和Little-Endian的定义如下:
  a) Little-Endian就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。
  b) Big-Endian就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。

网络字节序:4个字节的32 bit值以下面的次序传输:首先是0~7bit,其次8~15bit,然后16~23bit,最后是24~31bit。这种传输次序称作大端字节序。由于TCP/IP首部中所有的二进制整数在网络中传输时都要求以这种次序,因此它又称作网络字节序。字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序,一个字节的数据没有顺序的问题了。

所以:在将一个地址绑定到socket的时候,请先将主机字节序转换成为网络字节序,而不要假定主机字节序跟网络字节序一样使用的是Big-Endian,否则会导致很多莫名其妙的问题。所以请谨记对主机字节序不要做任何假定,务必将其转化为网络字节序再赋给socket。

8.listen()函数

#include <sys/types.h>
#include <sys/socket.h>
int listen(int sockfd, int backlog);
man手册中对这两个参数的解释如下:
参数sockfd:
    sockfd标识一个socket文件描述符,对应socket type为SOCK_STREAM或SOCK_SEQPACKET(The sockfd argument is a file descriptor that refers to a socket of type SOCK_STREAM or SOCK_SEQPACKET)。
参数backlog:
    The backlog argument defines the maximum length to which the queue ofpending connections for sockfd may grow. If a connection request arrives when the queue is full, the client may receive an error with an indication of ECONNREFUSED or, if the underlying protocol supports retransmission, the request may be ignored so that a later reattempt at connection succeeds.

a).backlog是等待连接队列的最大长度。例如,如果将backlog设置为10,则当有15个客户端连接请求的时候,前面10个连接请求就被放置在请求队列中,后面5个请求被拒绝。
b).当链接(connect)完成时,会从这个连接队列中移出一个(accept函数实现)。
c).当连接队列已满时,客户端会收到连接错误的返回值,或者如果底层网络支持重传的话,则服务端会忽略这个请求,依靠后续的重传来建立连接。

众所周知,TCP连接过程可以分为3次握手。服务端为了维护连接过程中的这些状态,需要开辟两个队列来存放这两个中间状态。一个叫“半连接队列”用来存放“SYN RECVD”状态的连接,一个叫“完整连接队列”用来存放“ESTABLISHED”状态的tcp连接。当处于“SYN RECVD”队列的连接,收到了客户端的ACK响应包的时候,这个连接会从“SYN RECVD”队列删除,并添加到”ESTABLISHED“队列的末尾,这两个队列大小的总和就是由backlog参数来决定的。当调用listen函数之后调用accept函数,其实是从“完整连接队列”里面取一个连接出来建立真正的tcp连接。
两个队列的总大小(backlog)在linux系统是有限制的,具体可以看cat /proc/sys/net/core/somaxconn,默认值是128。

另外,listen函数的man手册中,对backlog有下面的这段说明:
The behavior of the backlog argument on TCP sockets changed with Linux 2.2. Now it specifies the queue length for completely established sockets waiting to be accepted, instead of the number of incomplete connection requests. The maximum length of the queue for incomplete sockets can be set using /proc/sys/net/ipv4/tcp_max_syn_backlog. When syncookies are enabled there is no logical maximum length and this setting is ignored. See tcp(7) for more information.
在linux2.2之前,backlog在TCP中的行为有所改变。现在它用来表示完成了3次握手操作,状态已经变为了ESTABLISHED的队列长度,等待accept函数调用它。而以前是用来表示尚未完成连接的个数,即处于SYN RECVD状态。
对于尚未完成3次握手的连接的队列长度,可以使用/proc/sys/net/ipv4/tcp_max_syn_backlog来进行设置。默认值是512。
当syncookies设置为enable时,则 不会存在这种逻辑上的最大长度的限制。backlog这个配置项会被忽略掉。

9.connect()函数

#include <sys/types.h>
#include <sys/socket.h>
int connect(int sockfd, const struct sockaddr *addr,socklen_t addrlen);

9.1.connect()中TCP与UDP行为

connect是套接字连接操作,connect操作之后代表对应的套接字已连接,UDP协议在创建套接字之后,可以同多个服务器端建立通信,而TCP协议只能与一个服务器端建立通信,TCP不允许目的地址是广播或多播地址,UDP允许。当然UDP协议也可以像TCP协议一样,通过connect来指定对方的ip地址、端口。
UDP协议经过connect之后,在通过sendto来发送数据报时不需要指定目的地址、端口,如果指定了目的地址、端口,那么会返回错误。通过UDP协议可以给同一个套接字指定多次connect操作,而TCP协议不可以,TCP只能指定一次connect操作。UDP协议指定第二次connect操作之后会先断口第一次的连接,然后建立第二次的连接。

9.2.connect执行过程

在客户端执行connect连接服务端之前,一般都会存在下面的这两个步骤:
第一步都会通过socket建立连接套接字;
第二步通过bind来绑定本地地址、本地端口,当然绑定操作可以不用指定;
对于UDP协议:若未指定绑定操作,则可以通过后面的connect操作来由内核负责套接字的绑定操作;若connect也没有指定,那么绑定操作只好通过套接字的写操作(sendto、sendmsg)来指定目的地址、端口,这时套接字本地地址不会指定,为通配地址,而本地端口由内核指定,第一次sendto操作之后,scoket的本地端口经过内核指定之后就不会更改。
对于TCP协议:若未指定绑定操作,则可以通过后面的connect操作来由内核负责套接字的绑定操作。内核会根据套接字中的目的地址来判断外出接口,然后指定该外出接口的IP地址为插口的本地地址。Connect操作对于TCP协议的客户端是必不可少的,必须执行。

9.3.非阻塞connect

TCP连接的建立涉及到一个三次握手的过程,且SOCKET中connect函数需要一直等到客户接收到对于自己的SYN的ACK为止才返回,这意味着每个connect函数总会阻塞其调用进程至少一个到服务器的RTT时间,而RTT波动范围很大,从局域网的几个毫秒到几百个毫秒甚至广域网上的几秒。这段时间内,我们可以执行其他处理工作,以便做到并行。在此,需要用到非阻塞connect。

步骤1:设置非阻塞,启动连接
实现非阻塞 connect ,首先把 sockfd 设置成非阻塞的。这样调用connect 可以立刻返回,根据返回值和 errno 处理三种情况:
(1) 如果返回 0,表示 connect 成功。
(2) 如果返回值小于 0, errno 为 EINPROGRESS, 表示连接建立已经启动但是尚未完成。这是期望的结果,不是真正的错误。
(3) 如果返回值小于0,errno 不是 EINPROGRESS,则连接出错了。

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

int result = connect(sockfd, saptr, salen);
if (result == -1)
{
if (errno != EINPROGRESS)
return fail;
//else: The socket is non-blocking and the connection cannot be completed immediately
}
if (result == 0)
return succ;
步骤2:判断可读和可写
然后把 sockfd 加入 select 的读写监听集合,通过 select 判断 sockfd是否可写,处理三种情况:
(1) 如果连接建立好了,对方没有数据到达,那么 sockfd 是可写的
(2) 如果在 select 之前,连接就建立好了,而且对方的数据已到达,那么 sockfd 是可读和可写的。
(3) 如果连接发生错误,sockfd 也是可读和可写的。
判断 connect 是否成功,就得区别 (2) 和 (3),这两种情况下 sockfd 都是可读和可写的,区分的方法是,调用 getsockopt 检查是否出错。

fd_set wfd;
FD_ZERO(&wfd);
FD_SET(fd, &wfd);
if(select(sockfd+1, NULL, &wfd, NULL, timeoutptr) == -1)
{
close(fd);
return fail;
}

步骤3:使用getsockopt 函数检查错误
getsockopt(int sockfd, int level, int optname, void *optval, socklen_t *optlen);
在sockfd 都是可读和可写的情况下,我们使用 getsockopt 来检查连接是否出错。但这里有一个可移植性的问题。
如果发生错误,getsockopt 源自 Berkeley 的实现将在变量 error 中返回错误,getsockopt 本身返回0;然而 Solaris 却让 getsockopt 返回 -1,并把错误保存在 errno 变量中。所以在判断是否有错误的时候,要处理这两种情况。

int error=0, result=0;
if (FD_ISSET(sockfd, &wfd)) {
socklen_t length = sizeof(error);
result = getsockopt(sockfd, SOL_SOCKET, SO_ERROR, &error, &length);
if (result < 0 || error) {
close(sockfd);
if (error) {
errno = err;
return fail;
}
}
}

10.accept()函数

#include <sys/types.h>
#include <sys/socket.h>
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
accept函数的第一个参数为服务器的socket描述符,第二个参数为指向struct sockaddr *的指针,用于返回客户端的协议地址,第三个参数为协议地址的长度。如果accept成功,那么其返回值是由内核自动生成的一个全新的描述符,代表与返回客户的TCP连接。
注意:accept的第一个参数为服务器的socket描述符,是服务器开始调用socket()函数生成的,称为监听socket描述符;而accept函数返回的是已连接的socket描述符。一个服务器通常仅仅只创建一个监听socket描述字,它在该服务器的生命周期内一直存在。内核为每个由服务器进程接收的客户连接创建了一个已连接socket描述字,当服务器完成了对某个客户的服务,相应的已连接socket描述字就被关闭。

在non-blocking非阻塞模式下,如果返回值为-1,且errno == EAGAIN或errno == EWOULDBLOCK表示no connections没有新的连接请求。
什么情况下需要使用非阻塞的accept呢?如果我们使用select或者epoll对listenfd进行监控,正常情况下,三次握手完成之后,服务器端维护了一个队列来保存这些连接(参考第8小节对listen函数的介绍)。select或者epoll会触发listenfd的可读事件来等待程序调用accept函数返回这个连接的描述符。但是在一些特殊的情况下,调用close函数,并且(l_onoff = 1, l_linger =0)。这种情况是为了尽快回收套接字资源,不发送FIN报文,发送的是RST报文,套接字直接进入CLOSE状态,而不会进入TIME_WAIT状态。这种情况就要求非阻塞accept的存在。
假设这样一种情况:当客户端发起连接,完成了三次握手之后,此时服务器端因为很忙碌,还没有调用accept函数来确认这个连接。此时客户端使用close函数关闭了连接,注意此时客户端发送的是RST报文而不是FIN报文。所以该链接就在内核中就被断开了,但是此时用户层并不知道这种变化(这是因为berkeley的实现会在内核处理该事件,将该连接从已经建立好的连接队伍出删除,并不会通知服务端。而其他版本的实现则会返回ECONNABORTED或者EPROTO错误)。如果此时服务器端通过epoll触发的listenfd调用阻塞的accept,那么由于连接已经被取消,所以accept就会被阻塞,那么其它注册在epoll中的fd就不能被及时的处理。所以我们需要将listenfd设置为非阻塞的,保证完成accept是非阻塞的。

当设置accept是非阻塞的时,如果有连接存在,那么accept正常返回。如果时没有连接存在,或者像上述所说的连接被取消了,那么会返回不同的错误码。我们只需要在accept返回错误后检查这些错误码,如果是这两种情况(ECONNABORTED或EPROTO),把它当做正常情况不用处理即可。