鸿蒙源码分析(四)

本篇主要详细解释鸿蒙通信中的tcp_socket机制

本文主要解释tcp连接机制中,各个阶段对套接字的操作。

背景知识

一、TCP协议栈和两个socket缓冲区

        两个socket缓冲区:send buffer和recv buffer。
        要通过TCP连接发送出去的数据都先拷贝到send buffer,可能是从用户空间进程的app buffer拷入的,也可能是从内核的kernel buffer拷入的,拷入的过程是通过send()函数完成的,由于也可以使用write()函数写入数据,所以也把这个过程称为写数据,相应的send buffer也就有了别称write buffer。不过send()函数比write()函数更有效率。
        当通过TCP连接接收数据时,send buffer中的数据通过DMA的方式拷贝到网卡中并通过网络传输给TCP连接的另一端:接收端。数据肯定是先通过网卡流入的,然后同样通过DMA的方式拷贝到recv buffer中,再通过recv()函数将数据从recv buffer拷入到用户空间进程的app buffer中。
大致工作流程机制图如下:
在这里插入图片描述

二、socket中的三次握手协议

Socket:一种利用唯一标识(ip+端口)来进行通讯(数据交互)的方法。
Socket的基本操作:

socket中TCP的三次握手建立连接详解
tcp建立连接要进行“三次握手”,即交换三个分组。大致流程如下:

  • 客户端向服务器发送一个SYN J
  • 服务器向客户端响应一个SYN K,并对SYN J进行确认ACK J+1
  • 客户端再想服务器发一个确认ACK K+1

tcp_socket当中的三次握手协议示意图
在这里插入图片描述

三、两种套接字:监听套接字和已连接套接字。

监听套接字是在服务进程读取配置文件时,从配置文件中解析出要监听的地址、端口,然后通过socket()函数创建的,然后再通过bind()函数将这个监听套接字绑定到对应的地址和端口上。随后,进程/线程就可以通过listen()函数来监听这个端口(严格地说是监控这个监听套接字)。

已连接套接字是在监听到TCP连接请求并三次握手后,通过accept()函数返回的套接字,后续进程/线程就可以通过这个已连接套接字和客户端进行TCP通信。

为了区分socket()函数和accept()函数返回的两个套接字描述符,有些人使用listenfd和connfd分别表示监听套接字和已连接套接字,挺形象的,下文偶尔也这么使用。

下面就来说明各种函数的作用,分析这些函数,也是在连接、断开连接的过程。
连接的具体流程图:
在这里插入图片描述

1. socket()函数

socket()函数的作用就是生成一个用于通信的套接字文件描述符sockfd(socket() creates an endpoint for communication and returns a descriptor)。这个套接字描述符可以作为稍后bind()函数的绑定对象。

2. bind()函数

服务程序通过分析配置文件,从中解析出想要监听的地址和端口,再加上可以通过socket()函数生成的套接字sockfd,就可以使用bind()函数将这个套接字绑定到要监听的地址和端口组合"addr:port"上。绑定了端口的套接字可以作为listen()函数的监听对象。
绑定了地址和端口的套接字就有了源地址和源端口(对服务器自身来说是源),再加上通过配置文件中指定的协议类型,五元组中就有了其中3个元组。即:
{protocal,src_addr,src_port}
但是,常见到有些服务程序可以配置监听多个地址、端口实现多实例。这实际上就是通过多次socket()+bind()系统调用生成并绑定多个套接字实现的。

3. listen()函数

顾名思义,listen()函数就是监听已经通过bind()绑定了addr+port的套接字的。监听之后,套接字就从CLOSE状态转变为LISTEN状态,于是这个套接字就可以对外提供TCP连接的窗口了。

4. connect()函数

**而connect()函数则用于向某个已监听的套接字发起连接请求,也就是发起TCP的三次握手过程。**从这里可以看出,连接请求方(如客户端)才会使用connect()函数,当然,在发起connect()之前,连接发起方也需要生成一个sockfd,且使用的很可能是绑定了随机端口的套接字。既然connect()函数是向某个套接字发起连接的,自然在使用connect()函数时需要带上连接的目的地,即目标地址和目标端口,这正是服务端的监听套接字上绑定的地址和端口。同时,它还要带上自己的地址和端口,对于服务端来说,这就是连接请求的源地址和源端口。于是,TCP连接的两端的套接字都已经成了五元组的完整格式。

5. accept()函数

accpet()函数的作用是读取已完成连接队列中的第一项(读完就从队列中移除),并对此项生成一个用于后续连接的套接字描述符,假设使用connfd来表示。 有了新的连接套接字,工作进程/线程(称其为工作者)就可以通过这个连接套接字和客户端进行数据传输,而前文所说的监听套接字(sockfd)则仍然被监听者监听。
当监听者发起accept()系统调用的时候,如果已完成连接队列中没有任何数据,那么监听者会被阻塞。当然,可将套接字设置为非阻塞模式,这时accept()在得不到数据时会返回EWOULDBLOCK或EAGAIN的错误。可以使用select()或poll()或epoll来等待已完成连接队列的可读事件。还可以将套接字设置为信号驱动IO模式,让已完成连接队列中新加入的数据通知监听者将数据复制到app buffer中并使用accept()进行处理。

6. tcp连接和套接字的关系

每个tcp连接的两端都会关联一个套接字和该套接字指向的文件描述符。
当服务端收到了ack消息后,就表示三次握手完成了,表示和客户端的这个tcp连接已经建立好了。连接建立好的一开始,这个tcp连接会放在listen()打开的established queue队列中等待accept()的消费。这个时候的tcp连接在服务端所关联的套接字是listen套接字和它指向的文件描述符。
当established queue中的tcp连接被accept()消费后,这个tcp连接就会关联accept()所指定的套接字,并分配一个新的文件描述符。也就是说,经过accept()之后,这个连接和listen套接字已经没有任何关系了。
服务端会地换掉了这个tcp连接所关联的套接字和文件描述符,而客户端并不知道这一切。但这并不影响双方的通信,因为数据传输是基于连接而不是基于套接字的,只要能从文件描述符中将数据放入tcp连接这根"管道"里,数据就能到达另一端。
实际上,并不一定需要accept()才能进行tcp通信,因为在accept()之前连接就以建立好了,只不过它关联的是listen套接字对应的文件描述符,而这个套接字只识别三次握手和四次挥手涉及到的数据,而且这个套接字中的数据是由操作系统内核负责的 可以想像一下,只有listen()没有accept()时,客户端不断地发起connect(),服务端将一直将建立仅只连接而不做任何操作,直到listen的队列满了。

7. send()和recv()函数

send()函数是将数据从app buffer复制到send buffer中(当然,也可能直接从内核的kernel buffer中复制),recv()函数则是将recv buffer中的数据复制到app buffer中。 当然,对于tcp套接字来说,更多的是使用write()和read()函数来发送、读取socket buffer数据,这里使用send()/recv()来说明仅仅只是它们的名称针对性更强而已。

这两个函数都涉及到了socket buffer,但是在调用send()或recv()时,复制的源buffer中是否有数据、复制的目标buffer中是否已满而导致不可写是需要考虑的问题。不管哪一方,只要不满足条件,调用send()/recv()时进程/线程会被阻塞(假设套接字设置为阻塞式IO模型)。当然,可以将套接字设置为非阻塞IO模型,这时在buffer不满足条件时调用send()/recv()函数,调用函数的进程/线程将返回错误状态信息EWOULDBLOCK或EAGAIN。buffer中是否有数据、是否已满而导致不可写,其实可以使用select()/poll()/epoll去监控对应的文件描述符(对应socket buffer则监控该socket描述符),当满足条件时,再去调用send()/recv()就可以正常操作了。还可以将套接字设置为信号驱动IO或异步IO模型,这样数据准备好、复制好之前就不用再做无用功去调用send()/recv()了。

8. close()、shutdown()函数

通用的close()函数可以关闭一个文件描述符,当然也包括面向连接的网络套接字描述符。 当调用close()时,将会尝试发送send buffer中的所有数据。但是close()函数只是将这个套接字引用计数减1,就像rm一样,删除一个文件时只是移除一个硬链接数,只有这个套接字的所有引用计数都被删除,套接字描述符才会真的被关闭,才会开始后续的四次挥手中。对于父子进程共享套接字的并发服务程序,调用close()关闭子进程的套接字并不会真的关闭套接字,因为父进程的套接字还处于打开状态,如果父进程一直不调用close()函数,那么这个套接字将一直处于打开状态,将一直进入不了四次挥手过程。

而shutdown()函数专门用于关闭网络套接字的连接,和close()对引用计数减一不同的是,它直接掐断套接字的所有连接,从而引发四次挥手的过程。可以指定3种关闭方式:

1.关闭写。此时将无法向send buffer中再写数据,send buffer中已有的数据会一直发送直到完毕。
2.关闭读。此时将无法从recv buffer中再读数据,recv buffer中已有的数据只能被丢弃。
3.关闭读和写。此时无法读、无法写,send buffer中已有的数据会发送直到完毕,但recv buffer中已有的数据将被丢弃。

无论是shutdown()还是close(),每次调用它们,在真正进入四次挥手的过程中,它们都会发送一个FIN。

9. socket通信大致流程

在这里插入图片描述

10. 端口复用
函数:setsockopt
SO_REUSEADDR:地址复用
SO_REUSEPORT:端口复用

一般来说,一个{addr,port}只能被一个套接字绑定,即无法重用。
不同的套接字只能绑定到不同的{addr,port}上!
SO_REUSEADDR函数

功能:
若监听服务器进入TIME_WAIT状态,可立即重启
同一端口启动同一服务器的多个实例,需要每个实例套接字绑定不同的ip地址,一般需要多个网卡支持
支持完全重复的捆绑:
当一个IP地址和端口绑定到某个套接口上时,还允许此IP地址和端口捆绑到另一个套接口上。一般来说,这个特性仅在支持多播的系统上才有,而且只对UDP套接口而言(TCP不支持多播)。

对于监听线程来说,可重用套接字被称为监听桶(listener bucket),即每个套接字都是一个桶。

以event模型为例,假设目前有3个子进程,每个子进程中都有一个监听线程和多个工作线程。
端口未重用情况
在这里插入图片描述端口重用(2监听)
在这里插入图片描述
端口重用(3监听)
在这里插入图片描述
3个监听桶下,各子进程均不用让出监听权,可以无限监听。
似乎看上去非常美好,性能好。不仅减轻了“监听权”(互斥锁)的争用,避免了“饥饿”;还能更高效的监听,实现负载均衡,从而减轻监听线程的压力。
但由于监听过程需要消耗CPU,若是单核CPU,无法体现出端口复用的优势,反而会由于切换监听线程而降低性能。

所以端口复用中应当考虑的因素:

  • 是否将监听进程/线程隔离在各自CPU中
  • 重用次数
  • CPU核数

以上为tcp_socket中的原理和通信机制。

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值