You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

14 KiB

04 | TCP三次握手怎么使用套接字格式建立连接

你好我是盛延敏这里是网络编程实战第4讲欢迎回来。

在上一讲里我们介绍了IPv4、IPv6以及本地套接字格式这一讲我们来讲一讲怎么使用这些套接字格式完成连接的建立当然经典的TCP三次握手理论也会贯穿其中。我希望经过这一讲的讲解你会牢牢记住TCP三次握手和客户端、服务器模型。

让我们先从服务器端开始。

服务端准备连接的过程

创建套接字

要创建一个可用的套接字,需要使用下面的函数:

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

domain就是指PF_INET、PF_INET6以及PF_LOCAL等表示什么样的套接字。

type可用的值是

  • SOCK_STREAM: 表示的是字节流对应TCP
  • SOCK_DGRAM 表示的是数据报对应UDP
  • SOCK_RAW: 表示的是原始套接字。

参数protocol原本是用来指定通信协议的但现在基本废弃。因为协议已经通过前面两个参数指定完成。protocol目前一般写成0即可。

bind: 设定电话号码

创建出来的套接字如果需要被别人使用就需要调用bind函数把套接字和套接字地址绑定就像去电信局登记我们的电话号码一样。

调用bind函数的方式如下

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

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

这里其实可以把bind函数理解成这样

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

不过BSD设计套接字的时候大约是1982年那个时候的C语言还没有void *的支持为了解决这个问题BSD的设计者们创造性地设计了通用地址格式来作为支持bind和accept等这些函数的参数。

对于使用者来说每次需要将IPv4、IPv6或者本地套接字格式转化为通用套接字格式就像下面的IPv4套接字地址格式的例子一样

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

对于实现者来说可根据该地址结构的前两个字节判断出是哪种地址。为了处理长度可变的结构需要读取函数里的第三个参数也就是len字段这样就可以对地址进行解析和判断了。

设置bind的时候对地址和端口可以有多种处理方式。

我们可以把地址设置成本机的IP地址这相当告诉操作系统内核仅仅对目标IP是本机IP地址的IP包进行处理。但是这样写的程序在部署时有一个问题我们编写应用程序时并不清楚自己的应用程序将会被部署到哪台机器上。这个时候可以利用通配地址的能力帮助我们解决这个问题。通配地址相当于告诉操作系统内核“Hi我可不挑活只要目标地址是咱们的都可以。”比如一台机器有两块网卡IP地址分别是202.61.22.55和192.168.1.11那么向这两个IP请求的请求包都会被我们编写的应用程序处理。

那么该如何设置通配地址呢?

对于IPv4的地址来说使用INADDR_ANY来完成通配地址的设置对于IPv6的地址来说使用IN6ADDR_ANY来完成通配地址的设置。

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

除了地址还有端口。如果把端口设置成0就相当于把端口的选择权交给操作系统内核来处理操作系统内核会根据一定的算法选择一个空闲的端口完成套接字的绑定。这在服务器端不常使用。

一般来说服务器端的程序一定要绑定到一个众所周知的端口上。服务器端的IP地址和端口数据相当于打电话拨号时需要知道的对方号码如果没有电话号码就没有办法和对方建立连接。

我们来看一个初始化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接上电话线一切准备就绪

bind函数只是让我们的套接字和地址关联如同登记了电话号码。如果要让别人打通电话还需要我们把电话设备接入电话线让服务器真正处于可接听的状态这个过程需要依赖listen函数。

初始化创建的套接字,可以认为是一个"主动"套接字其目的是之后主动发起请求通过调用connect函数后面会讲到。通过listen函数可以将原来的"主动"套接字转换为"被动"套接字,告诉操作系统内核:“我这个套接字是用来等待用户请求的。”当然,操作系统内核会为此做好接收用户请求的一切准备,比如完成连接队列。

listen函数的原型是这样的

int listen (int socketfd, int backlog)

我来稍微解释一下。第一个参数socketdf为套接字描述符第二个参数backlog在Linux中表示已完成(ESTABLISHED)且未accept的队列大小这个参数的大小决定了可以接收的并发数目。这个参数越大并发数目理论上也会越大。但是参数过大也会占用过多的系统资源一些系统比如Linux并不允许对这个参数进行改变。对于backlog整个参数的设置有一些最佳实践这里就不展开后面结合具体的实例进行解读。

accept: 电话铃响起了……

当客户端的连接请求到达时,服务器端应答成功,连接建立,这个时候操作系统内核需要把这个事件通知到应用程序,并让应用程序感知到这个连接。这个过程,就好比电信运营商完成了一次电话连接的建立, 应答方的电话铃声响起,通知有人拨打了号码,这个时候就需要拿起电话筒开始应答。

连接建立之后你可以把accept这个函数看成是操作系统内核和应用程序之间的桥梁。它的原型是

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

函数的第一个参数listensockfd是套接字可以叫它为listen套接字因为这就是前面通过bindlisten一系列操作而得到的套接字。函数的返回值有两个部分第一个部分cliadd是通过指针方式获取的客户端的地址addrlen告诉我们地址的大小这可以理解成当我们拿起电话机时看到了来电显示知道了对方的号码另一个部分是函数的返回值这个返回值是一个全新的描述字代表了与客户端的连接。

这里一定要注意有两个套接字描述字第一个是监听套接字描述字listensockfd它是作为输入参数存在的第二个是返回的已连接套接字描述字。

你可能会问,为什么要把两个套接字分开呢?用一个不是挺好的么?

这里和打电话的情形非常不一样的地方就在于,打电话一旦有一个连接建立,别人是不能再打进来的,只会得到语音播报:“您拨的电话正在通话中。”而网络程序的一个重要特征就是并发处理,不可能一个应用程序运行之后只能服务一个客户,如果是这样, 双11抢购得需要多少服务器才能满足全国 “剁手党 ” 的需求?

所以监听套接字一直都存在它是要为成千上万的客户来服务的直到这个监听套接字关闭而一旦一个客户和服务器连接成功完成了TCP三次握手操作系统内核就为这个客户生成一个已连接套接字让应用服务器使用这个已连接套接字和客户进行通信处理。如果应用服务器完成了对这个客户的服务,比如一次网购下单,一次付款成功,那么关闭的就是已连接套接字这样就完成了TCP连接的释放。请注意这个时候释放的只是这一个客户连接其它被服务的客户连接可能还存在。最重要的是监听套接字一直都处于“监听”状态等待新的客户请求到达并服务。

客户端发起连接的过程

前面讲述的bind、listen以及accept的过程是典型的服务器端的过程。下面我来讲下客户端发起连接请求的过程。

第一步还是和服务端一样,要建立一个套接字,方法和前面是一样的。

不一样的是客户端需要调用connect向服务端发起请求。

connect: 拨打电话

客户端和服务器端的连接建立是通过connect函数完成的。这是connect的构建函数

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

函数的第一个参数sockfd是连接套接字通过前面讲述的socket函数创建。第二个、第三个参数servaddr和addrlen分别代表指向套接字地址结构的指针和该结构的大小。套接字地址结构必须含有服务器的IP地址和端口号。

客户在调用函数connect前不必非得调用bind函数因为如果需要的话内核会确定源IP地址并按照一定的算法选择一个临时端口作为源端口。

如果是TCP套接字那么调用connect函数将激发TCP的三次握手过程而且仅在连接建立成功或出错时才返回。其中出错返回可能有以下几种情况

  1. 三次握手无法建立客户端发出的SYN包没有任何响应于是返回TIMEOUT错误。这种情况比较常见的原因是对应的服务端IP写错。
  2. 客户端收到了RST复位回答这时候客户端会立即返回CONNECTION REFUSED错误。这种情况比较常见于客户端发送连接请求时的请求端口写错因为RST是TCP在发生错误时发送的一种TCP分节。产生RST的三个条件是目的地为某端口的SYN到达然而该端口上没有正在监听的服务器如前所述TCP想取消一个已有连接TCP接收到一个根本不存在的连接上的分节。
  3. 客户发出的SYN包在网络上引起了"destination unreachable",即目的不可达的错误。这种情况比较常见的原因是客户端和服务器端路由不通。

根据不同的返回值,我们可以做进一步的排查。

著名的TCP三次握手: 这一次不用背记


你在各个场合都会了解到著名的TCP三次握手可能还会被要求背下三次握手整个过程但背后的原理和过程可能未必真正理解。我们刚刚学习了服务端和客户端连接的主要函数下面结合这些函数讲解一下TCP三次握手的过程。这样我相信你不用背也能根据理解轻松掌握这部分的知识。

这里我们使用的网络编程模型都是阻塞式的。所谓阻塞式,就是调用发起后不会直接返回,由操作系统内核处理之后才会返回。 相对的,还有一种叫做非阻塞式的,我们在后面的章节里会讲到。

TCP三次握手的解读

我们先看一下最初的过程服务器端通过socketbind和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状态。

形象一点的比喻是这样的有A和B想进行通话

  • A先对B说“喂你在么我在的我的口令是j。”
  • B收到之后大声回答“我收到你的口令j并准备好了你准备好了吗我的口令是k。”
  • A收到之后也大声回答“我收到你的口令k并准备好了我们开始吧。”

可以看到这样的应答过程总共进行了三次这就是TCP连接建立之所以被叫为“三次握手”的原因了。

总结

这一讲我们分别从服务端和客户端的角度讲述了如何创建套接字并利用套接字完成TCP连接的建立。

  • 服务器端通过创建socketbindlisten完成初始化通过accept完成连接的建立。
  • 客户端通过创建socketconnect发起连接建立请求。

在下一讲里,我们将真正地开始客户端-服务端数据交互的过程。

思考题

最后给你布置两道思考题。

第一道是关于阻塞调用的,既然有阻塞调用,就应该有非阻塞调用,那么如何使用非阻塞调用套接字呢?使用的场景又是哪里呢?

第二道是关于客户端的客户端发起connect调用之前可以调用bind函数么

欢迎你在评论区与我分享你的答案如果这篇文章帮助你理解TCP三次握手也欢迎你点击“请朋友读”把这篇文章分享给你的朋友或者同事。