TCP和UDP简介

了解TCP和UDP之前先了解socket,使用TCP和UDP通信socket必不可少

socket


socket简单的理解为软件与硬件之间沟通的桥梁,可以理解为一个电话,创建socket使用的接口:

int socket(int domain, int type, int protocol)
  • 参数domain

    domain表示地址族,常用地址族有AF_LOCAL,AF_INET,AF_INET6

    AF_LOCAL:表示的是本地地址,对应的是 Unix套接字,这种情况一般用于本地socket通信,也可以写成AF_UNIX、AF_FILE

    AF_INET:因特网使用的 IPv4 地址

    AF_INET6:因特网使用的 IPv6 地址

  • 参数type

    SOCK_STREAM: 表示的是字节流,对应TCP协议

    SOCK_DGRAM: 表示的是数据报,对应UDP协议

    SOCK_RAW:表示的是原始套接字

  • 参数protocol

    此参数原本是用来指定通信协议的,现在基本废弃,传入0即可

  • 返回值

    当返回值<=0时,说明创建失败

套接字地址格式


有了电话,对于连接发起方(主动拨打电话方),需要知道对方的电话号码,电话号码有可能是固定电话、手机,固定电话有固定电话的格式,手机有手机的格式,所以就有了对格式解析的方式,分为:

  • 通用地址格式

    通用地址格式结构体:

      /* POSIX.1g 规范规定了地址族为2字节的值. */
      typedef unsigned short int sa_family_t;
    	
      /* 描述通用套接字地址 */
      struct sockaddr
      { 
          /* 地址族. 16-bit*/ 
          sa_family_t sa_family; 
    		
          /* 具体的地址值 112-bit */ 
          char sa_data[14]; 
      };
    

    sa_family表示地址族,表示以什么方式对地址进行解析、保存,就像手机格式、固定电话格式,就是上面提到的

    sa_data表示具体地址了,就像电话号码

  • IPv4地址格式

    IPv4地址格式的结构体:

      /* IPV4套接字地址,32bit值. */
      typedef uint32_t in_addr_t;
    	
      struct in_addr 
      { 
          in_addr_t s_addr; 
      }; 
    	
      /* 描述IPV4的套接字地址格式 */
      struct sockaddr_in 
      { 
          /* 地址族. 16-bit*/ 
          sa_family_t sin_family; 
    		
          /* 端口号 16-bit*/ 
          in_port_t sin_port; 
    		
          /* Internet address. 32-bit */ 
          struct in_addr sin_addr; 
    		
          /* 这里仅仅用作占位符,不做实际用处 */ 
          unsigned char sin_zero[8]; 
      };
    

    sun_family表示地址族,固定为AF_INET

    sin_port表示要监听(对于服务端来说)或者连接(对于客户端来说)的端口号,端口号占16位,也就是说2的16次方(65535)个端口号可用,去掉保留的端口号,剩下的就是用户可用的

    sin_addr表示ip地址,占32位,也就是说可用2的32次方个ip地址,但是依然不够用,后来有了IPv6

    对于连接的发起方可以这样编写:

      int network::SocketWrapper::Connect(const char* ip, short port)
      {
          struct sockaddr_in server_sock_addr;
          memset(&server_sock_addr, 0, sizeof(server_sock_addr));
    	
          server_sock_addr.sin_family = AF_INET;
          server_sock_addr.sin_port = htons(port);
          IPToN(AF_INET, ip, &server_sock_addr.sin_addr);
    	
          return ::connect(socket_, (struct sockaddr*)&server_sock_addr, sizeof(server_sock_addr));
      }
    
  • IPv6地址格式

    IPv6地址格式的结构体:

      struct sockaddr_in6 
      { 
          /* 16-bit */
          sa_family_t sin6_family; 
    		
          /* 传输端口号 # 16-bit */ 
          in_port_t sin6_port; 
    		
          /* IPv6流控信息 32-bit*/
          uint32_t sin6_flowinfo;  
    		
          /* IPv6地址128-bit */
          struct in6_addr sin6_addr;  
    		
          /* IPv6域ID 32-bit */
          uint32_t sin6_scope_id;  
      };
    

    sun_family表示地址族,固定为AF_INET6

    sin6_addr表示ip地址,占128位,那么可用的地址比IPv4大的太多了

  • 本地地址格式

    IPv4地址格式和IPv6地址格式都是因特网套接字的格式,本地套接字地址格式:

      struct sockaddr_un 
      { 
          /* 固定为 AF_LOCAL */
          unsigned short sun_family; 
    		
          /* 路径名 */
          char sun_path[108]; 
      };
    

    sun_family表示地址族,固定为AF_LOCAL

下图可以清晰的看到这几种地址格式:

Alt text

创建TCP服务


创建一个简单的TCP服务需要以下几步:

  1. 创建socket

    上面提到的系统函数

     int socket(int domain, int type, int protocol)
    
  2. bind

    bind函数就是把第1步创建的套接字和套接字地址绑定,就像去电信运营商申请电话号码一样,关于更多bind函数的相关知识可以参考这篇文章,bind函数签名如下:

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

    参数fd:第1步创建的套接字

    参数addr:通用地址格式指针

    参数len:自己选择的地址格式长度

    摘自网络库的一段代码:

     int network::SocketWrapper::bind(const char* ip, short port)
     {
         // 绑定到port和ip
         struct sockaddr_in server_sock_addr;
    	
         // IPV4
         server_sock_addr.sin_family = AF_INET;
         // 指定端口
         server_sock_addr.sin_port = htons(port);
    	
         IPToN(AF_INET, ip, &server_sock_addr.sin_addr);
    	
         return ::bind(socket_, (struct sockaddr *) &server_sock_addr, sizeof(server_sock_addr));
     }
    

    这段代码使用了IPv4地址格式,当调用系统函数::bind时需要将IPv4地址格式转换成了通用地址格式,对于::bind函数的实现者需要处理不同地址格式,那么就需要调用者传递其选择的地址格式长度, 这样就可以对地址进行解析

    这个函数有两个参数——ip、port,如果服务程序接收的是本机的消息,并不区分网卡,即本机的任何ip即可,这种情况可以利用通配地址来实现:

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

    同样的port也可以交给系统来选择,即由系统选择一个未使用端口,传递为0的参数即可,但是对于服务器程序这样做并不是一个明智的选择,因为服务器程序要将端口暴露给客户端

  3. listen

    有了电话,也注册了电话号码,还需要接上电话线,接电话线的过程就是这一步——listen,listen的函数签名:

     int listen (int socketfd, int backlog)
    

    参数socketfd:第1步创建的套接字(电话)

    参数backlog:关于这个参数的解释可以参考这篇文章,这部电话和现实中的电话不同的是:现实中一部电话只能接受一个用户拨打, 服务器程序的电话,可以接受多个用户拨打

  4. accept

    有人打电话了,服务器程序要接起电话,当电话铃响起了,TCP连接已经完成了三次握手,连接已经建立,accept函数签名:

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

    参数listensockfd:第1步创建的套接字(电话)

    参数cliaddr:通过指针方式获取的客户端的地址

    参数addrlen:客户端的地址长度

    返回值:新的套接字,代表与客户端的一条连接,以后服务器与这个客户端的通信就使用这个套接字,断开连接时回收掉的也是这个套接字,这个连接不复存在了

  5. recv

    接起电话后,听对方说话,讲出自己想说的话,这个过程就是通信,recv函数签名:

     int recv(socket s, char *buf, int len,int flags)
    

    参数s:accept函数返回的新套接字

    参数buf:应用程序缓冲区

    参数len:应用程序想要读取的字节数

    参数flags:标志位,通常传递0

    返回值:实际读取的字节数

    对这个函数的调用有很多规则,后面会用一个篇幅来专门详细讲

上面简单的列举了创建一个简单的TCP服务需要的步骤,达到生产级别还很远,先有个宏观概念,会有详细文章一层一层剖析

创建TCP连接


有了服务器程序,还需要创建发起连接的客户端程序,创建客户端程序需要以下步骤:

  1. 创建socket

    和创建TCP服务程序一样,先有电话

  2. connect

    和服务器建立连接,这里有一个问题:在第1步和第2步之间不需要bind函数吗?即打电话场景中的去电信运营商申请电话号码一样,先记下这个问题,先看看connect函数签名:

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

    参数sockfd:第1步创建的套接字

    参数servaddr:服务器地址格式

    参数addrlen:地址格式长度

    摘自网络库的一段代码:

     int network::SocketWrapper::Connect(const char* ip, short port)
     {
         struct sockaddr_in server_sock_addr;
         memset(&server_sock_addr, 0, sizeof(server_sock_addr));
    	
         server_sock_addr.sin_family = AF_INET;
         server_sock_addr.sin_port = htons(port);
         IPToN(AF_INET, ip, &server_sock_addr.sin_addr);
    	
         return ::connect(socket_, (struct sockaddr*)&server_sock_addr, sizeof(server_sock_addr));
     }
    

    参数ip:服务器程序ip

    参数port:服务器程序监听的端口

    返回值:连接结果,当返回值<0时说明连接出错了,此时可以调用系统函数查看这个错误码,连接过程会触发TCP三次握手,连接成功也就意味着三次握手成功,连接出错通常有以下几种情况:

    • 三次握手无法建立

      客户端发出的 SYN 包没有任何响应,于是返回 TIMEOUT 错误。这种情况比较常见的原因是对应的服务端 IP 写错

    • 客户端收到了 RST(复位)回答,这时候客户端会立即返回 CONNECTION REFUSED 错误

      这种情况比较常见于客户端发送连接请求时的请求端口写错,因为RST是TCP在发生错误时发送的一种TCP分节。产生RST的三个条件是:

      1. 目的地为某端口的 SYN 到达,然而该端口上没有正在监听的服务器(如前所述)
      2. TCP 想取消一个已有连接
      3. TCP 接收到一个根本不存在的连接上的分节
    • 客户发出的 SYN 包在网络上引起了”destination unreachable”

      即目的不可达的错误。这种情况比较常见的原因是客户端和服务器端路由不通

  3. send

    连接建立后,就可以收发消息了,这里使用的send函数签名是这样的:

     int send (int socketfd, char *buffer, int size, int flags)
    

    参数socked:第1步创建的socket

    参数buffer:客户端程序待发送消息缓存,

    参数size:想要发送的消息长度

    参数flags:标记位,可以指定选项是否发送带外数据,带外数据,是一种基于 TCP 协议的紧急数据,用于客户端-服务器在特定场景下的紧急处理

    返回值:实际发送的消息长度

    关于发送消息的接口不只有这一个,并且对于返回值的处理,后面会详细介绍,至此一个简单地基于TCP客户端-服务器通信程序可以编写了,距离生产级别还很远,一层一层的拨开面纱

发起TCP连接的一端可不可以使用bind


上面提到了一个问题:发起连接的一端需不需要调用bind()

答案是:没必要调用bind()。此答案可以解释为:可以调用bind(),但是没有必要;当然也可以不调用

如果调用bind(),可能有端口冲突的风险,而对于客户端程序只需要关心和服务器能不能建立连接,以及建立连接后的消息处理。发起连接时,对于4元组中的源IP、源端口的选择交给系统即可,所以 没必要调用bind(),下面给出一个发起TCP连接调用前bind()的例子:

int NetWorkCenter::CreateTcpConnC2SWithBind(const char* ip, short port, const char* dest_ip, short dest_port)
{
	...

	SharedSockType sock = std::make_shared<SocketWrapper>(ip, port);

	/* 创建字节流类型的IPV4 socket. */
	sock->CreateSocket(SOCK_STREAM);

	if (!sock->IsGood())
	{
		ERROR_INFO("the created socket isnt good");
		return 0;
	}

	if (sock->bind() < 0)
	{
		ERROR_INFO("bind faild");
		return 0;
	}

	int ret = sock->Connect(dest_ip, dest_port);
	if (ret < 0)
	{
		ERROR_INFO("connect failed err_no:{0}", CatchLastError());
		return 0;
	}

	...

	return sock->GetSocket();
}

int network::SocketWrapper::bind()
{
	// 绑定到port和ip
	struct sockaddr_in sock_addr;

	// IPV4
	sock_addr.sin_family = AF_INET;
	// 指定端口,本地5701端口
	sock_addr.sin_port = addr_.Port();
	
	// 指定Ip,"0.0.0.0"
	sock_addr.sin_addr.s_addr = addr_.Ip();

	return ::bind(socket_, (struct sockaddr *) &sock_addr, sizeof(sock_addr));
}

Connect函数原型已经在上面给出了,测试这个知识点时,发生一个有意思的事情,当启动服务器后,客户端也能连接成功,但是从服务器端查看客户端的端口却不是客户端指定的端口,起初以为客户端程序的问题 ,各种分析问题,折腾半天时间,盯着服务器程序思考了一段时间,有点怀疑人生…,恍然大悟,服务器程序部署在阿里云上,而客户端程序在公司和家里的内网环境下,虽然家里使用的是中国电信,但是分配的是个内网网段 ,这对于普通用户无伤大雅,能上网、打游戏,不管是中国电信还是公司内网其实所有用户使用的都是固定的一个公网IP,相当于服务器程序和客户端程序之间有个Proxy,为了验证我的猜测,我将服务器程序 部署在同一网段下,再次测试发现服务器程序显示的是客户端指定的端口

TCP三次握手


上面提到TCP连接发起后会触发TCP三次握手,先看一下三次握手的过程:

Alt text

TCP三次握手的过程由操作系统内核网络协议栈完成,根据上图描述,其过程如下:

  1. 客户端的协议栈向服务器端发送了SYN包,并告诉服务器端当前发送序列号j,客户端进入SYNC_SENT状态

  2. 服务器端的协议栈收到这个包之后,和客户端进行ACK应答,应答的值为j+1,表示对SYN包j的确认,同时服务器也发送一个SYN包,告诉客户端当前服务器端的发送序列号为k, 服务器端进入SYNC_RCVD状态

  3. 客户端协议栈收到ACK之后,使得应用程序从connect调用返回,表示客户端到服务器端的单向连接建立成功(C——>S),客户端的状态为ESTABLISHED状态, 同时客户端协议栈也会对服务器端的SYN包进行应答,应答数据为k+1

  4. 应答包到达服务器端后,这个时候服务器端到客户端的单向连接也建立成功(S——>C),此连接在服务器端也进入ESTABLISHED状态

TCP四次挥手


有握手就有再见,正常关闭一个连接的过程会触发四次挥手,四次挥手的过程如下图:

Alt text

先梳理一下四次挥手的步骤:

  1. 不管是客户端还是服务器端应用程序,主动发起close的一方称为主动关闭方

    主动关闭方先发起close,向被动关闭方发送一个FIN包,序列号为m,如上图,此时这个连接在主动关闭方的状态为FIN_WAIT_1

  2. 被动关闭方收到FIN包

    被动关闭方收到FIN包,该连接被动关闭,被动关闭方进入CLOSE_WAIT状态,回复给主动关闭方一个ACK,同时对序列号m+1, 这个FIN由TCP协议栈处理,TCP协议栈为FIN包插入一个文件结束符EOF到接收缓冲区中,CLOSE_WAIT状态发生在被动关闭方

  3. 被动关闭方收到FIN包的处理

    应用程序可以通过read调用来感知这个FIN包。这个EOF会被放在已排队等候的其他已接收的数据之后,这就意味着接收端应用程序需要处理这种异常情况, 因为EOF表示在该连接上再无额外数据到达,如果被动关闭方处理了EOF,并且close这个socket,这就导致被动关闭方向主动关闭方发送了一个FIN包,序列号为n, 此时,被动关闭方进入LAST_ACK状态,见上图

  4. 主动关闭方收到FIN包

    当主动关闭方收到被动关闭方的FIN包时,主动关闭方进入TIME_WAIT状态,TIME_WAIT状态发生在主动关闭方,同时向被动关闭方发送ACK,同时对序列号n+1, 此时,被动关闭方收到主动关闭方的ACK后进入CLOSED状态,而主动关闭方需经过2个MSL后进入CLOSED状态

上述过程便是TCP的四次挥手,顺便提一下,当套接字被关闭时,TCP为其所在端发送一个FIN包。在大多数情况下,这是由应用程序调用 close 而发生的, 一个进程无论是正常退出(exit 或者 main 函数返回),还是非正常退出(例如:通过kill -9,收到 SIGKILL 信号关闭),所有该进程打开的描述符都会被系统关闭, 这也导致TCP描述符对应的连接上发出一个FIN包,关于更多如何关闭连接可以参考这篇文章