网络编程学习笔记(二)--连接与通信

使用套接字格式建立连接

服务端准备连接的过程

  一、创建套接字:

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

  二、绑定地址:

1
bind(int fd, sockaddr * addr, socklen_t len);

  bind函数后面的第二个参数是通用地址格式sockaddr。这里有一个地方值得注意,那就是虽然接收的是通用地址格式,实际上传入的参数可能是IPv4、IPv6或者本地套接字格式。bind函数会根据len字段判断传入的参数addr该怎么解析,len字段表示的就是传入的地址长度,它是一个可变值。这里其实可以把bind函数理解成这样:

1
bind(int fd, void * addr, socklen_t len);

  不过BSD设计套接字的时候大约是1982年,那个时候的C语言还没有void指针的支持,为了解决这个问题,BSD的设计者们创造性地设计了通用地址格式来作为支持bind和accept等这些函数的参数。对于使用者来说,每次需要将IPv4、IPv6或者本地套接字格式转化为通用套接字格式,就像下面的IPv4套接字地址格式的例子一样:

1
2
struct sockaddr_in name; 
bind (sock, (struct sockaddr *) &name, sizeof (name);

  设置bind的时候,对地址和端口可以有多种处理方式。我们可以把地址设置成本机的IP地址,这相当告诉操作系统内核,仅仅对目标IP是本机IP地址的IP包进行处理。但是这样写的程序在部署时有一个问题,我们编写应用程序时并不清楚自己的应用程序将会被部署到哪台机器上。这个时候,可以利用通配地址的能力帮助我们解决这个问题。
  对于IPv4的地址来说,使用INADDR_ANY来完成通配地址的设置;对于IPv6的地址来说,使用IN6ADDR_ANY来完成通配地址的设置。如下:

1
2
struct sockaddr_in name;
name.sin_addr.s_addr = htonl (INADDR_ANY); /* IPV4 通配地址 */

  除了地址,还有端口。一般来说,服务器端的程序一定要绑定到一个众所周知的端口上。服务器端的IP地址和端口数据,相当于打电话拨号时需要知道的对方号码,如果没有电话号码,就没有办法和对方建立连接。
  三、监听端口:
  初始化创建的套接字,可以认为是一个”主动”套接字,其目的是之后主动发起请求(通过调用connect函数)。通过listen函数,可以将原来的”主动”套接字转换为”被动”套接字,告诉操作系统内核:“我这个套接字是用来等待用户请求的。”当然,操作系统内核会为此做好接收用户请求的一切准备,比如完成连接队列。listen函数的原型是这样的:

1
int listen (int socketfd, int backlog);

  第一个参数socketfd为套接字描述符,第二个参数backlog,官方的解释为未完成连接队列的大小,这个参数的大小决定了可以接收的并发数目。这个参数越大,并发数目理论上也会越大。但是参数过大也会占用过多的系统资源,一些系统,比如Linux并不允许对这个参数进行改变。
  四、接收请求
  当客户端的连接请求到达时,服务器端应答成功,连接建立,这个时候操作系统内核需要把这个事件通知到应用程序,并让应用程序感知到这个连接。accept这个函数的作用就是连接建立之后,操作系统内核和应用程序之间的桥梁。它的原型是:

1
int accept(int listensockfd, struct sockaddr *cliaddr, socklen_t *addrlen);

  函数的第一个参数listensockfd是套接字,可以叫它为listen套接字,因为这就是前面通过bind,listen一系列操作而得到的套接字。函数的返回值有两个部分,第一个部分cliadd是通过指针方式获取的客户端的地址,addrlen告诉我们地址的大小。另一个部分是函数的返回值,这个返回值是一个全新的描述字,代表了与客户端的连接。这里一定要注意有两个套接字描述字,第一个是监听套接字描述字listensockfd,它是作为输入参数存在的;第二个是返回的已连接套接字描述字。为什么要把两个套接字分开呢?这是因为网络程序的一个重要特征就是并发处理,不可能一个应用程序运行之后只能服务一个客户,所以监听套接字一直都存在,它是要为成千上万的客户来服务的,直到这个监听套接字关闭;而一旦一个客户和服务器连接成功,完成了TCP三次握手,操作系统内核就为这个客户生成一个已连接套接字,让应用服务器使用这个已连接套接字和客户进行通信处理。如果应用服务器完成了对这个客户的服务,比如一次网购下单,一次付款成功,那么关闭的就是已连接套接字,这样就完成了TCP连接的释放。请注意,这个时候释放的只是这一个客户连接,其它被服务的客户连接可能还存在。最重要的是,监听套接字一直都处于“监听”状态,等待新的客户请求到达并服务。

客户端发起连接的过程

  一、创建套接字:和服务端一样的做法。
  二、connect:客户端和服务器端的连接建立,是通过connect函数完成的。这是connect的构建函数:

1
int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen);

  函数的第一个参数sockfd是连接套接字,通过前面讲述的socket函数创建。第二个、第三个参数servaddr和addrlen分别代表指向套接字地址结构的指针和该结构的大小。套接字地址结构必须含有服务器的IP地址和端口号。如果是TCP套接字,那么调用connect函数将激发TCP的三次握手过程,而且仅在连接建立成功或出错时才返回。其中出错返回可能有以下几种情况:
  1.三次握手无法建立,客户端发出的SYN包没有任何响应,于是返回TIMEOUT错误。这种情况比较常见的原因是对应的服务端IP写错。
  2.客户端收到了RST(复位)回答,这时候客户端会立即返回CONNECTION-REFUSED错误。这种情况比较常见于客户端发送连接请求时的请求端口写错,因为RST是TCP在发生错误时发送的一种TCP分节。产生RST的三个条件是:目的地为某端口的SYN到达,然而该端口上没有正在监听的服务器(如前所述);TCP想取消一个已有连接;TCP接收到一个根本不存在的连接上的分节。
  3.客户发出的SYN包在网络上引起了”destination-unreachable”,即目的不可达的错误。这种情况比较常见的原因是客户端和服务器端路由不通。

TCP三次握手

  先看一下最初的过程,服务器端通过socket,bind和listen完成了被动套接字的准备工作,被动的意思就是等着别人来连接,然后调用accept,就会阻塞在这里,等待客户端的连接来临;客户端通过调用socket和connect函数之后,也会阻塞。接下来的事情是由操作系统内核完成的,更具体一点的说,是操作系统内核网络协议栈在工作。
  下面是具体的过程:
  1. 客户端的协议栈向服务器端发送了SYN包,并告诉服务器端当前发送序列号j,客户端进入SYNC_SENT状态;
  2.服务器端的协议栈收到这个包之后,和客户端进行ACK应答,应答的值为j+1,表示对SYN包j的确认,同时服务器也发送一个SYN包,告诉客户端当前我的发送序列号为k,服务器端进入SYNC_RCVD状态;
  3.客户端协议栈收到ACK之后,使得应用程序从connect调用返回,表示客户端到服务器端的单向连接建立成功,客户端的状态为ESTABLISHED,同时客户端协议栈也会对服务器端的SYN包进行应答,应答数据为k+1;
  4.应答包到达服务器端后,服务器端协议栈使得accept阻塞调用返回,这个时候服务器端到客户端的单向连接也建立成功,服务器端也进入ESTABLISHED状态。

使用套接字进行读写

发送数据

  发送数据时常用的有三个函数,分别是write、send和sendmsg。

1
2
3
ssize_t write (int socketfd, const void *buffer, size_t size); 
ssize_t send (int socketfd, const void *buffer, size_t size, int flags);
ssize_t sendmsg(int sockfd, const struct msghdr *msg, int flags);

  每个函数都是单独使用的,使用的场景略有不同:第一个函数是常见的文件写函数,如果把socketfd换成文件描述符,就是普通的文件写入。如果想指定选项,发送带外数据,就需要使用第二个带flag的函数。所谓带外数据,是一种基于TCP协议的紧急数据,用于客户端-服务器在特定场景下的紧急处理。如果想指定多重缓冲区传输数据,就需要使用第三个函数,以结构体msghdr的方式发送数据。
  当TCP三次握手成功,TCP连接成功建立后,操作系统内核会为每一个连接创建配套的基础设施,比如发送缓冲区。发送缓冲区的大小可以通过套接字选项来改变,当我们的应用程序调用write函数时,实际所做的事情是把数据从应用程序中拷贝到操作系统内核的发送缓冲区中,并不一定是把数据通过套接字写出去。这里有几种情况:第一种情况很简单,操作系统内核的发送缓冲区足够大,可以直接容纳这份数据,那么皆大欢喜,我们的程序从write调用中退出,返回写入的字节数就是应用程序的数据大小。第二种情况是,操作系统内核的发送缓冲区是够大了,不过还有数据没有发送完,或者数据发送完了,但是操作系统内核的发送缓冲区不足以容纳应用程序数据,在这种情况下,你预料的结果是什么呢?报错?还是直接返回?操作系统内核并不会返回,也不会报错,而是应用程序被阻塞,也就是说应用程序在write函数调用处停留,不直接返回。术语“挂起”也表达了相同的意思,不过“挂起”是从操作系统内核角度来说的。
  那么什么时候才会返回呢?实际上,每个操作系统内核的处理是不同的。大部分UNIX系统的做法是一直等到可以把应用程序数据完全放到操作系统内核的发送缓冲区中,再从系统调用中返回。当TCP连接建立之后,它就开始运作起来。可以把发送缓冲区想象成一条包裹流水线,有个聪明且忙碌的工人不断地从流水线上取出包裹(数据),这个工人会按照TCP/IP的语义,将取出的包裹(数据)封装成TCP的MSS包,以及IP的MTU包,最后走数据链路层将数据发送出去。这样我们的发送缓冲区就又空了一部分,于是又可以继续从应用程序搬一部分数据到发送缓冲区里,这样一直进行下去,到某一个时刻,应用程序的数据可以完全放置到发送缓冲区里。在这个时候,write阻塞调用返回。注意返回的时刻,应用程序数据并没有全部被发送出去,发送缓冲区里还有部分数据,这部分数据会在稍后由操作系统内核通过网络发送出去。

读取数据

  read函数,这个函数的原型如下:

1
ssize_t read (int socketfd, void *buffer, size_t size);

  read函数要求操作系统内核从套接字描述字socketfd读取最多多少个字节(size),并将结果存储到buffer中。返回值告诉我们实际读取的字节数目,也有一些特殊情况,如果返回值为0,表示EOF(end-of-file),这在网络中表示对端发送了FIN包,要处理断连的情况;如果返回值为-1,表示出错。当然,如果是非阻塞I/O,情况会略有不同。注意这里是最多读取size个字节。如果我们想让应用程序每次都读到size个字节,就需要编写下面的函数,不断地循环读取。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* 从 socketfd 描述字中读取 "size" 个字节. */ 
ssize_t readn(int fd, void *vptr, size_t size)
{
size_t nleft; ssize_t nread;
char *ptr;
ptr = vptr;
nleft = size;
while (nleft > 0)
{
if ( (nread = read(fd, ptr, nleft)) < 0)
{
if (errno == EINTR)
nread = 0; /* 这里需要再次调用 read */
else
return(-1);
}
else if (nread == 0)
break; /* EOF(End of File) 表示套接字关闭 */
nleft -= nread;
ptr += nread;
}
return(n - nleft); /* 返回的是实际读取的字节数 */
}

  需要注意:
  1、阻塞式套接字最终发送返回的实际写入字节数和请求字节数是相等的。
  2、发送成功仅仅表示的是数据被拷贝到了发送缓冲区中,并不意味着连接对端已经收到所有的数据。至于什么时候发送到对端的接收缓冲区,或者更进一步说,什么时候被对方应用程序缓冲所接收,对我们而言完全都是透明的。
  3、对于send来说,返回成功仅仅表示数据写到发送缓冲区成功,并不表示对端已经成功收到。对于read来说,需要循环读取数据,并且需要考虑EOF等异常条件。

UDP编程的情况

  
  UDP和TCP编程非常不同,下面这张图是UDP程序设计时的主要过程。

  可以看到服务器端创建UDP套接字之后,绑定到本地端口,调用recvfrom函数等待客户端的报文发送;客户端创建套接字之后,调用sendto函数往目标地址和端口发送UDP报文,然后客户端和服务器端进入互相应答过程。recvfrom和sendto是UDP用来接收和发送报文的两个主要函数:

1
2
ssize_t recvfrom(int sockfd, void *buff, size_t nbytes, int flags, struct sockaddr *from, socklen_t *addrlen); 
ssize_t sendto(int sockfd, const void *buff, size_t nbytes, int flags, const struct sockaddr *to, socklen_t *addrlen);

  先来看一下recvfrom函数,sockfd、buff和nbytes是前三个参数。sockfd是本地创建的套接字描述符,buff指向本 地的缓存,nbytes表示最大接收数据字节。第四个参数flags是和I/O相关的参数,这里我们还用不到,设置为0。后面两个参数from和addrlen,实际上是返回对端发送方的地址和端口等信息,这和TCP非常不一样,TCP是通过accept函数拿到的描述字信息来决定对端的信息。另外UDP报文每次接收都会获取对端的信息,也就是说报文和报文之间是没有上下文的。函数的返回值告诉我们实际接收的字节数。
  接下来看一下sendto函数。sendto函数中的前三个参数为sockfd、buff和nbytes。sockfd是本地创建的套接字描述符,buff指向发送的缓存,nbytes表示发送字节数。第四个参数flags依旧设置为0。后面两个参数to和addrlen,表示发送的对端地址和端口等信息。函数的返回值告诉我们实际接收的字节数。
  UDP是无连接的数据报程序,和TCP不同,不需要三次握手建立一条连接。UDP程序通过recvfrom和sendto函数直接收发数据报报文。

本地套接字

  实际上,本地套接字是IPC,也就是本地进程间通信的一种实现方式。除了本地套接字以外,其它技术,诸如管道、共享消息队列等也是进程间通信的常用方法,但因为本地套接字开发便捷,接受度高,所以普遍适用于在同一台主机上进程间通信的各种场景。
  本地套接字是一种特殊类型的套接字,和TCP/UDP套接字不同。TCP/UDP即使在本地地址通信,也要走系统网络协议栈,而本地套接字,严格意义上说提供了一种单主机跨进程间调用的手段,减少了协议栈实现的复杂度,效率比TCP/UDP套接字都要高许多。类似的IPC机制还有UNIX管道、共享内存和RPC调用等。
  本地字节流套接字和TCP服务器端、客户端编程最大的差异就是套接字类型的不同。本地字节流套接字识别服务器不再通过IP地址和端口,而是通过本地文件。本地套接字的编程接口和IPv4、IPv6套接字编程接口是一致的,可以支持字节流和数据报两种协议。本地套接字的实现效率大大高于IPv4和IPv6的字节流、数据报套接字实现。