(1) 服务端准备连接的过程

socket创建套接字

int socket(int domain, int type, int protocol)
参数domain 就是指 PF_INETPF_INET6 以及 PF_LOCAL 等,表示什么样的套接字。
参数type 可用的值是:
  • SOCK_STREAM: 表示的是字节流,对应 TCP;
  • SOCK_DGRAM: 表示的是数据报,对应 UDP;
  • SOCK_RAW: 表示的是原始套接字。
参数 protocol 原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成。protocol 目前一般写成 0 即可。

bind绑定地址

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

通配地址

我们可以把地址设置成本机的 IP 地址,这相当告诉操作系统内核,仅仅对 目标IP 是 本机IP地址 的IP包进行处理。但是这样写的程序在部署时有一个问题,我们编写应用程序时并不清楚自己的应用程序将会被部署到哪台机器上。这个时候,可以利用通配地址的能力帮助我们解决这个问题。
struct sockaddr_in name;
name.sin_addr.s_addr = htonl (INADDR_ANY); /* IPV4通配地址 */
如果把端口设置成 0,就相当于把端口的选择权交给操作系统内核来处理,操作系统内核会根据一定的算法选择一个空闲的端口,完成套接字的绑定。这在服务器端不常使用。
一般来说,服务器端的程序一定要绑定到一个众所周知的端口上

初始化 IPv4 TCP套接字的例子

#include <stdio.h>
#include <stdlib.h>
#include <sys/socket.h>
#include <netinet/in.h>

int make_socket (uint16_t port)
{
  int sock;
  struct sockaddr_in name;


  /* 创建字节流类型的IPV4 socket. */
  sock = socket (PF_INET, SOCK_STREAM, 0);
  if (sock < 0)
    {
      perror ("socket");
      exit (EXIT_FAILURE);
    }

  /* 绑定到port和ip. */
  name.sin_family = AF_INET; /* IPV4 */
  name.sin_port = htons (port);  /* 指定端口 */
  name.sin_addr.s_addr = htonl (INADDR_ANY); /* 通配地址 */
  /* 把IPV4地址转换成通用地址格式,同时传递长度 */
  if (bind (sock, (struct sockaddr *) &name, sizeof (name)) < 0)
    {
      perror ("bind");
      exit (EXIT_FAILURE);
    }

  return sock
}

listen监听

初始化创建的套接字,可以认为是一个"主动"套接字,其目的是之后主动发起请求(通过调用 connect 函数,后面会讲到)。通过 listen 函数,可以将原来的"主动"套接字转换为"被动"套接字,告诉操作系统内核:“我这个套接字是用来等待用户请求的。”当然,操作系统内核会为此做好接收用户请求的一切准备,比如完成连接队列
int listen (int socketfd, int backlog)
第一个参数 socketdf 为套接字描述符。
第二个参数 backlog,在Linux中表示已完成 (ESTABLISHED) 且未accept 的队列大小这个参数的大小决定了可以接收的并发数目。(这个参数越大,并发数目理论上也会越大。但是参数过大也会占用过多的系统资源)

accept建立连接

int accept(int listensockfd, struct sockaddr *cliaddr, socklen_t *addrlen)
第一个参数 listensockfd 是套接字,可以叫它为 listen套接字,因为这就是前面通过 bind,listen 一系列操作而得到的套接字。
函数的返回值有两个部分:
        第一个部分 cliaddr 是通过指针方式获取的客户端的地址,addrlen 告诉我们地址的大小,
        另一个部分是函数的返回值,这个返回值是一个全新的描述字,代表了与客户端的连接
注:
监听套接字一直都存在,它是要为成千上万的客户来服务的,直到这个监听套接字关闭;而一旦一个客户和服务器连接成功,完成了TCP三次握手,操作系统内核就为这个客户生成一个 已连接套接字
让应用服务器使用这个已连接套接字和客户进行通信处理。如果应用服务器完成了对这个客户的服务,那么关闭的就是已连接套接字,这样就完成了 TCP 连接的释放。
请注意,这个时候释放的只是这一个客户连接,其它被服务的客户连接可能还存在。最重要的是,监听套接字一直都处于“监听”状态,等待新的客户请求到达并服务

(2) 客户端发起连接的过程

connect发起连接

int connect(int sockfd, const struct sockaddr *servaddr, socklen_t addrlen)
第一个参数 sockfd 是连接套接字,通过前面讲述的 socket 函数创建。
第二个参数 servaddr 代表指向套接字地址结构的指针。(套接字地址结构必须含有服务器的 IP 地址和端口号。)
第三个参数 addrlen 代表套接字地址结构的大小。
注:客户在调用函数connect前不必非得调用 bind 函数,因为如果需要的话,内核会确定源 IP 地址,并按照一定的算法选择一个临时端口作为源端口。

如果是 TCP 套接字,那么调用connect函数将激发 TCP的三次握手过程,而且仅在连接建立成功或出错时才返回。其中出错返回可能有以下几种情况:
  • 三次握手无法建立,客户端发出的 SYN 包没有任何响应,于是返回 TIMEOUT 错误。这种情况比较常见的原因是对应的服务端IP写错
  • 客户端收到了 RST(复位)回答,这时候客户端会立即返回 CONNECTION REFUSED 错误这种情况比较常见的原因是客户端发送连接请求时的请求端口写错。因为 RST 是 TCP 在发生错误时发送的一种 TCP 分节。 (产生 RST 的三个条件是(1) 目的地为某端口的 SYN 到达,然而该端口上没有正在监听的服务器(如前所述);(2) TCP想取消一个已有连接;(3) TCP接收到一个根本不存在的连接上的分节)
  • 客户发出的 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 状态

思考

1. 既然有阻塞调用,就应该有非阻塞调用,那么如何使用非阻塞调用套接字呢?使用的场景又是哪里呢?
非阻塞调用的场景就是 高性能服务器编程!我所有的调用都不需要等待对方准备好了再返回,而是立即返回,那么我怎么知道是否准备好了?就是这些fd注册到类似select或者epoll这样的调用中(IO多路转接),变多个fd阻塞为一个fd阻塞,只要有任何一个fd准备好了,select或者epoll都会返回,然后我们在从中取出准备好了的fd进行各种IO操作
2. 客户端发起 connect 调用之前,可以调用 bind 函数么?
客户端可以调用bind函数指定使用 固定端口 来连接否则,操作系统则会为之分配一个 随机端口 来连接