进程间通信(7)——套接字

0. 前言

进程通信的概念最初来源于单机系统。由于每个进程都在自己的地址范围内运行,为保证两个相互通信的进

程之间既互不干扰又协调一致工作,操作系统为进程通信提供了相应设施,如:

UNIX BSD:管道(pipe)命名管道(fifo)信号(sinal)

UNIX system:消息队列(message)共享内存(shm)信号量(semaphore)

它们都仅限于本地进程间通信。而网络间通信要解决的是不同主机进程间的通信问题(可把同机进程间通信看成一个特例)。同一主机上,不同进程可用进程号(process ID)唯一标识。但在网络环境下,各主机独立分配的进程号不能唯一标识该进程。例如,主机A赋于某进程号5,在B机中也可以存在5号进程,因此,“5号进程”这句话就没有意义了。 其次,操作系统支持的网络协议众多,不同协议的工作方式不同,地址格式也不同。因此,网间进程通信还要解决多重协议的识别问题。 

TCP/IP协议族已经帮我们解决了这个问题,网络层的 “ip地址” 可以唯一标识网络中的主机,而传输层的 “协议+端口” 可以唯一标识主机中的应用程序(进程)。这样利用三元组(ip地址,协议,端口)就可以标识网络的进程了,网络中的进程通信就可以利用这个标志与其它进程进行交互。

使用TCP/IP协议的应用程序通常采用应用编程接口:UNIX  BSD的套接字(socket)和UNIX System V的TLI(已经被淘汰),来实现网络进程之间的通信。就目前而言,几乎所有的应用程序都是采用 socket,而现在又是网络时代,网络中进程通信是无处不在,这就是我为什么说“一切皆socket”。

1. socket 套接字

socket 套接字是通信断点的抽象。与应用程序要使用文件描述符访问文件一样,访问套接字也需要套接字描述符。套接字描述符在 UNIX 系统是用文件描述符实现的,因此很多处理文件描述符的函数(如 read 和write)也可以调用socket 描述符。

2. socket()

#include <sys/socket.h>

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

该函数用以创建一个套接字描述符,该描述符唯一标识一个 socket。

返回值:

  • 若成功,返回socket描述符;
  • 若出错,放回-1;

参数:

  • domain:域,确定通信的特性,详细看第 2.1 节;
  • type:确定socket 的类型,进一步确定通信特征,详细看第 2.2 节;
  • protocol:通常为0,表示按照给定的域和套接字type 选择默认协议。当然,对同一域和socket type支持多个协议时,可以使用 protocol 参数选择一个特定的协议。
    •  在AF_INET 通信域、socket type为 SOCK_STREAM 默认协议是TCP;
    • 在AF_INET 通信域、socket type为 SOCK_DGRAM 默认协议是UDP; 

2.1 参数domain

域的类型大致分:

  • AF_INET:   IPv4 因特网域;
  • AF_INET6: IPv6 因特网域;
  • AF_UNIX:   UNIX 域,多数系统还会定义 AF_LOCAL,这个是AF_UNIX 别名;
  • AF_UNSPEC:未指定,可以代表任何域;

UNIX 域套接字用于在同一台机器上运行的进程之间的通信。虽然因特网域套接字可以用于同一目的,但UNIX 域套接字的效率更高。UNIX 域仅仅复制数据,它们并不执行协议处理,不需要添加或删除网络报头,无需计算校验和,不要产生顺序号,无需发送确认报文。

UNIX 域套接字提供流和数据报两种接口。UNIX域数据报服务是可靠的,既不会丢失消息也不会传递出错。UNIX 域套接字是 套接字管道 之间的混合物。

为了创建一对非命名的、相互连接的 UNIX 域套接字,用户可以使用它们面向网络的域套接字接口,也可以使用 socketpair() 函数。

#include <sys/socket.h>

int socketpair(int domain, int type, int protocol, int sockfd[2]);

                                            返回值:若成功返回0,若出错返回-1

2.2 参数type

type 用于确定套接字的类型:

  • SOCK_STREAM:又称字节流,有序、可靠、双向的面向连接字节流
  • SOCK_DGRAM:又称数据报,长度固定、无连接的不可靠的报文传递;
  • SOCK_RAW:IP 协议的数据报接口;
  • SOCK_SEQPACKET:长度固定、有序、可靠的面向连接报文传递;
     

数据报(SOCK_DGRAM)是一种自包含报文。发送数据报近似于给某人邮寄信件。可以邮寄很多信,但不能保证投递的次序,并且可能有些信件丢失在路上。每一封信件包含接收者的地址,使这封信件独立于所有其他信件。每一封信件可能送达不同的接收者。

对于字节流(SOCK_STREAM),应用程序意识不到报文界限,因为套接字提供的是字节流服务。这意味着当从套接字读出数据时,它也许不会返回所有由发送进程所写的字节数。最终可以获取发送过来的所有数据,但也许要通过若干次函数调用得到。

SOCK_SEQPACKET 与 SOCK_STREAM 套接字类似,但从该套接字的大的是基于报文的服务而不是字节流服务。这意味着从 SOCK_SEQPACKET 套接字接收的数据量与对方所发送的一致。流控制传输协议(Stream Control Transmission Protocol,SCTP)提供了因特网域上的顺序数据包服务。

SOCK_RAW 套接字提供一个数据报接口用于直接访问下面的网络层(在因特网域中为 IP)。使用这个套接字,应用程序负责构造自己的协议首部,用以防止恶意程序绕过内建安全机制来创建报文。

3. bind()

#include <sys/socket.h>

int bind(int sockfd, const struct sockaddr *addr, socklen_t len);

                                    返回值:若成功返回0,若出错返回-1

对于服务器来说,需要给一个接收客户端请求的套接字绑定一个众所周知的地址。使用 bind() 函数将地址绑定到套接字。

而客户端不用指定,由系统自动分配一个端口号和自身的IP 地址组合。

参数:

  • sockfd:通过 socket() 创建的套接字描述符,将会是addr 绑定到该套接字上;
  • addr:指向要绑定给 sockfd 的协议地址,这个地址结构根据创建 socket 时的地址协议族不同而不同;
  • len:对应地址的长度;

重点来关注下参数 addr,这个地址结构根据创建 socket 时的地址协议族不同而不同。

地址的格式与特定的通信域是有关联的,为了让不同格式的地址能够传入到套接字函数,地址被强制转换成通用的 sockaddr 表示:

struct sockaddr {
	sa_family_t	sa_family;	/* address family, AF_xxx	*/
	char		sa_data[];
    .
    .
    .
};

例如在linux 中,该结构定义如下:

struct sockaddr {
	sa_family_t	sa_family;	/* address family, AF_xxx	*/
	char		sa_data[14];	/* 14 bytes of protocol address	*/
};

在freeBSD 中,该结构定义如下:

struct sockaddr {
    unsigned char sa_len;     /*total length*/
	sa_family_t	sa_family;    /*address family*/
	char		sa_data[14];  /*variable-length address*/
};

在IPv4 因特网域中,该结构定义如下:

struct sockaddr_in {
  sa_family_t	    sin_family;	/* Address family		*/
  in_port_t		    sin_port;	/* Port number			*/
  struct in_addr	sin_addr;	/* Internet address		*/
}

struct in_addr {
	uint32_t    s_addr;        /* address in network byte order */
};

对于所能使用的地址有一些限制:

  • 在进程所运行的机器上,指定的地址必须有效,不能指定一个其他机器的地址;
  • 地址必须和创建套接字时的地址族所支持的格式相匹配;
  • 端口号必须不小于1024,除非该进程具有相应的特权(即为超级用户);
  • 一般只有套接字断点能够与地址绑定,尽管有些协议允许多重绑定;

3.1 网络字节序和主机字节序

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

  • Little-Endian 就是低位字节排放在内存的低地址端,高位字节排放在内存的高地址端。
  • Big-Endian   就是高位字节排放在内存的低地址端,低位字节排放在内存的高地址端。

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

所以,在将一个地址绑定到socket的时候,请先将主机字节序转换成为网络字节序,而不要假定主机字节序跟网络字节序一样使用的是Big-Endian。由于这个问题曾引发过血案!公司项目代码中由于存在这个问题,导致了很多莫名其妙的问题,所以请谨记对主机字节序不要做任何假定,务必将其转化为网络字节序再赋给socket。

3.2 getsockname()

#include <sys/socket.h>

int getsockname(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict alenp);

                                         返回值:若成功返回0,若出错返回-1

可以通过调用函数 getsockname() 来发现绑定到一个套接字的地址。

在调用 getsockname() 之前,设置 alenp 为一个指向整数的指针,该整数指定缓冲区 sockaddr 的大小。返回时,该整数会被设置成返回地址的大小。如果该地址和提供的缓冲区长度不匹配,则将其截断而不报错。如果当前没有绑定到套接字的地址,其结果没有定义。

4. connect()

如果处理的是面向连接的网络服务(SOCK_STREAM 或 SOCK_SEQPACKET),在开始交换数据之前,需要在请求服务的进程套接字 (客户端) 和提供服务的进程套接字(服务端)之间建立一个连接。

#include <sys/socket.h>

int connect(int sockfd, const struct sockaddr *addr, socklen_t len);

                                           返回值:若成功返回0,若出错返回-1

在connect() 中所指定的地址是想与之通信的服务器地址。如果 sockfd 没有绑定到一个地址,connect() 会给调用者绑定一个默认的地址。

当连接一个服务器时,出于一些原因,连接可能会失败。要连接的机器必须开启并且正在运行,服务器必须绑定到一个想与之连接的地址,并且在服务器的等待连接队列中应有足够的空间。因此,应用程序必须能够处理 connect() 返回的错误,这些错误可能由一些瞬时变化条件引起。

5. listen()

#include <sys/socket.h>

int listen(int sockfd, int backlog);

                                             返回值:若成功返回0,若出错返回-1

参数:

  • sockfd:需要监听的套接字描述符;
  • backlog:用以限定连接请求的数量,一旦队列满,系统会拒绝多余连接请求;

socket() 函数创建的socket 默认是一个主动类型的,listen() 将socket 变成被动类型的,等待客户的连接请求。

6. accept()

#include <sys/socket.h>

int accept(int sockfd, struct sockaddr *restrict addr, socklen_t *restrict len);

                                             返回值:若成功返回socket描述符,若出错返回-1

当服务器调用 listen() 监听函数之后,socket 就能接受连接请求,使用 accept() 获取连接请求并建立连接。

当 accept() 成功返回,这个返回值为一个 socket 描述符,用以连接到调用 connect() 的客户端。这个新的套接字描述符 (又称连接socket)与 原始的sockfd (又称监听socket) 具有相同的socket type和地址族。

如果不关心客户端标识,可以将参数 addr 和 len 设为NULL,否则在调用 accept() 之前,应将参数 addr 设为足够大的缓冲区来存放地址,并且将 len 设为指向代表缓冲区大小的整数的指针。返回时,accept() 会在缓冲区填充客户端的地址并且更新指针 len 所指向的整数为该地址的大小。

如果没有连接到来,accept() 会阻塞直到连接请求的到来。

7. 数据传输

  • read()  /  write()
  • sned() /  recv()
  • sendto()  /  recvfrom()
  • sendmsg()  / recvmsg()

socket 数据传输大致分为上面 4 组,除了 read() 、write() 系统调用,socket 还提供了三组数据传输接口。

7.1 send()

#include <sys/socket.h>

ssize_t send(int sockfd, const void *buf, size_t nbytes, int flags);

                                         返回值:若成功返回发送字节数,若出错返回-1

类似于 write(),使用send() 时套接字必须已经连接。

如果 send() 成功返回,并不必然表示连接另一端的进程接收数据。所保证的仅是当send() 成功返回,数据已经无错误地发送到网络上。

send() 返回值有三种可能:

  • 大于0:表示发送出去的数据长度;
  • 等于0:表示超时或对端主机关闭;
  • 小于0:异常;

对于支持为报文设限的协议,如果单个报文超过协议所支持的最大尺寸,send() 失败并将 errno 设为 EMSGSIZE;对于字节流协议,sned() 会阻塞直到整个数据被传输。

sned() 的第四个参数 flags(一般为0)指定标志来改变处理传输数据的方式:

  • MSG_DONTROUTE:勿将数据路由出本地数据;
  • MSG_DONTWAI:允许非阻塞操作,等价于使用 O_NONBLOCK;
  • MSG_EOR:如果协议支持,此为记录结束;
  • MSG_OOB: 如果协议支持,发送带外数据

7.1.1 带外数据

带外数据 (Out-of-band data),是一些通信协议所支持的可选特征,允许更高优先级的数据比普通数据优先传输。即使传输队列已经有数据,代码数据先行传输。

TCP 支持带外数据,但 UDP 不支持。socket 接口对带外数据的支持,很大程度受 TCP 带外数据具体实现的影响。

7.2 recv()

#include <sys/socket.h>

ssize_t recv(int sockfd, const void *buf, size_t nbytes, int flags);

                                         返回值:若成功返回接收消息字节数,若出错返回-1

recv() 与 read() 很像,但是允许指定选项来控制如何接收数据:

  • MSG_OOB:如果协议支持,接收带外数据
  • MSG_PEEK:返回报文内容而不真正取走报文;
  • MSG_TRUNC:即使报文被截断,要求返回的是报文的实际长度;
  • MSG_WAITALL:等待知道所有的数据可用 (仅对 SOCK_STREAM 类型);

对于 SOCK_STREAM 类型的socket,接收的数据可以比请求的少。但标志 MSG_WAITALL 阻止这种行为,除非所需数据全部收到,recv() 函数才能返回。

对于 SOCK_DGRAM 和 SOCK_SEQPACKET 类型的 socket,MSG_WAITALL 标志没有改变什么行为,因为这些基于报文的socket 类型一次读取就返回整个报文。

如果发送者已经调用 shutdown() 来结束传输,或者网络协议支持默认的顺序关闭并且发送端已经关闭,那么当所有的数据接收完毕后,recv() 返回0。

7.3 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

sendto() 与 send() 类似,区别在于 sendto() 允许在无连接的 socket 上指定一个目标地址。

对于面向连接的 socket 通信,目标地址是忽略的,因为目标地址蕴含在连接上。对于无连接的 socket,不能使用 send(),除非在调用 connect() 时预先设定了目标地址,或者采用 sendto() 来提供另外一种发送报文方式。

7.4 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);

                                         返回值:若成功返回接收消息字节数,若出错返回-1

如果 addr 非空,它将包含数据发送者的socket 端点地址。当调用 recvfrom() 时,需要设置 addrlen 参数指向一个包含 addr 所指的 socket 缓冲区大小的整数。返回时,该整数设为该地址的实际字节大小。

因为可以获得发送者的地址,recvfrom() 通常用于无连接 socket。否则,recvfrom() 等同于 recv()。

返回值同 recv()。

7.5 sendmsg()

#include <sys/socket.h>

ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);

                                         返回值:若成功返回发送字节数,若出错返回-1

参数:

sockfd:指定发送数据的 sockt fd;

msg:指向 msghdr 结构体的指针,该结构体包含了要发送的消息和相关元数据;

flags:标志;

来看下msghdr 结构体:

/* Structure describing messages sent by
    `sendmsg' and received by `recvmsg'.  */
struct msghdr
{
    void *msg_name;     /* Address to send to/receive from.  */
    socklen_t msg_namelen;  /* Length of address data.  */

    struct iovec *msg_iov;  /* Vector of data to send/receive into.  */
    size_t msg_iovlen;      /* Number of elements in the vector.  */

    void *msg_control;      /* Ancillary data (eg BSD filedesc passing). */
    size_t msg_controllen;  /* Ancillary data buffer length.
                   !! The type should be socklen_t but the
                   definition of the kernel is incompatible
                   with this.  */

    int msg_flags;      /* Flags on received message.  */
};
  • msg_name:指向要发送 / 接收信息的 socket 地址的结构体指针;
  • msg_namelen:socket 地址结构长度;
  • msg_iov:iov 数组指针,每个元素都包含了一块待发送数据的缓冲区及长度;
  • msg_iovlen:iov数组中元素个数;
  • msg_control:指向辅助数据的缓冲区;
  • msg_controllen:辅助数据缓冲区长度;
  • msg_flags:标志位;

sendmsg() 函数通过这些成员来描述待发送消息,并将其送往目标socket。具体实现上,sendmsg函数会将待发送消息划分成多个部分(即iov数组中的元素),然后逐个发送。在传输过程中可能会遇到各种情况(如网络延迟、丢包等),此时可以根据flags参数进行相应设置以达到期望效果。

7.6 recvmsg()

#include <sys/socket.h>

ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);

                                         返回值:若成功返回接收消息字节数,若出错返回-1

同sendmsg(),为了将接收到的数据送入多个缓冲区,或者想接收辅助数据,可以使用 recvmsg()函数。

结构 msghdr 被 recvmsg() 用于指定接收数据的输入缓冲区。可以设置参数 flags 来改变 recvmsg 的默认行为。返回时,msghdr 结构中的 msg_flags 字段被设为所接收数据的各种特征。

从 recvmsg() 中返回的 msg_flags 标志:

  • MSG_CTRUNC:控制数据被截断;
  • MSG_DONTWAIT:recvmsg() 处于非阻塞模式;
  • MSG_EOR:接收到记录结束符;
  • MSG_OOB:接收到带外数据;
  • MSG_TRUNC:一般数据被截断;

8. close()

在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用fclose关闭打开的文件。

#include <unistd.h>

int close(int fd);

close 一个TCP socket的缺省行为时把该socket标记为以关闭,然后立即返回到调用进程。该描述字不能再由调用进程使用,也就是说不能再作为read或write的第一个参数。

注意:close操作只是使相应socket描述字的引用计数-1,只有当引用计数为0的时候,才会触发TCP客户端向服务器发送终止连接请求。

9. socket 中 TCP 通信过程

上图是 socket 中 TCP 的通信过程,大致分为四个部分:

  • 初始化阶段,服务端通过 socket() 接口创建socket fd,并通过 bind() 接口对服务器地址端口绑定,最后通过 listen() 对该 socket fd进行监听,服务器正式进入监听阶段;客户端通过 socket() 接口创建socket fd;
  • 创建连接阶段,服务端在 listen() 之后调用 accept() 阻塞等待客户端链接,客户端通过 connect() 尝试连接;
  • 数据传输阶段,服务端和客户端进行数据通信;
  • 通信终止阶段,终止阶段客户端和服务器端会进入挥手流程;

9.1 三次握手建立连接

握手之前服务端有两个状态:

  • 最初的默认 CLOSED 状态,客户端connect() 之前也是处于 CLOSED 状态;
  • listen() 调用之后的 listen 状态;

当客户端调用 connect() 正式进入创建连接阶段,过程中有三次握手过程:

  • 第一次握手,客户端调用 connect(),向服务端发送 syn 包(同步序列编号Synchronize Sequence Numbers),syn = j,客户端进入 SYN_SEND 状态等待服务器确认;
  • 第二次握手,服务器收到客户端 syn 包并确认(ack=j+1),同时向客户端发送一个syn 包 (syn=k),即 SYN + ACK,服务器进入 SYN_RECV 状态;
  • 第三次握手,客户端收到服务器端的 SYN + ACK 包,向服务器发送确认包 (ack=k+1),此包发送完毕,客户端和服务器进入 ESTABLISHED 状态,完成握手;

当三次握手完成,客户端和服务器端进入 ESTABLISHED 状态,两端就可以进行数据传输。

TCP 握手的过程实际上是在通知对方自己的初始化序号(Initial Sequence Number),简称 ISN。该序号会在之后数据传输中当做一个依据,以保证 TCP 报文在传输过程中不会混乱。

我们回到TCP Header结构来看,Sequence Number和Acknowledgment Number都是占32位,所以seq和ack的取值范围是 0 ~ 2^32 -1。
seq和ack每增加到 2^32-1,则重新从0开始。值得一提的是,seq的初始值(ISN)并不是每次都从0开始的。我们设想一下,如果是从 0 开始,那么当 TCP 三次握手建立连接完成后,Client发送了30 个报文,然后 Client 断线了。于是 Client 重连,再次用 0 作为初始的seq,这样就会出现两个报文具有相同的 seq,就出现了混乱。
事实上 TCP 的做法是每隔 4 微秒就对 ISN 做一次加 1 操作,当 ISN 到达2^32-1后再次从 0 开始的时候,已经过去了几个小时,之前的 seq=0 的报文已经不存在于这次连接中了,这样就避免了上面的问题。

9.2 四次挥手断开连接

  • 第一次挥手,Client 向 Server 发送断开连接请求(seq=m),用来关闭 Client  到 Server 的数据传送,Client  进入FIN_WAIT-1 状态。m 为 Client 最后一次向 Server 发送报文段最后一个字节序号+1;
  • 第二次挥手,Server 收到 Client  请求,向 Client  发送确认报文 (seq=n,ack=m+1),Server 进入 CLOSE_WAIT状态。此时这个 TCP 连接处于半开半闭状态,Server 发送数据的话,Client 能接收到。n 为 Server 最后一次向 Client 发送报文段最后一个字节序号+1;
  • 第三次挥手,Server 向 Client 发送断开确认报文 (seq=u,ack=m+1), Server 进入LAST_ACK 状态。u 为半开半闭状态下 Server 最后一次向 Client 发送报文段最后一个字节序号+1;
  • 第四次挥手,Client 收到 Server 断开确认报文后,向 Server 发送确认断开报文 (seq=m+1, ack=u+1)。Client 进入 TIME_WAIT 状态。

当 Server 收到 Client 的ACK,进入 CLOSED 状态,断开 TCP 连接。Client 在 TIME_WAIT 状态等待一段时间,确认 Client 与 Server 的最后一次断开确认到达(如果没有到达,Server会重发步骤(3)中的断开确认报文段给Client,告诉Client你的最后一次确认断开没有收到)。如果Client在TIME-WAIT过程中没有再次收到Server的报文段,就进入CLOSES状态。TCP连接至此断开。

相关博文:

进程间通信(0)——序 

进程间通信(1)——信号(Signal)

进程间通信(2)——管道(PIPE)

进程间通信(3)——命名管道(FIFO)

进程间通信(4)——消息队列

进程间通信(5)——共享内存

进程间通信(6)——信号量(semaphore​​​​​​)

进程间通信(7)——套接字(socket)

猜你喜欢

转载自blog.csdn.net/jingerppp/article/details/131600206