TCP socket原理与编程实践

1. TCP中的阻塞/非阻塞

1.1 内核读缓冲区:

存储内核从外部接收到的数据,供用户程序读取

*recv(SOCKET fd, char buf, int len, int flag)**用于socket的读取,即从内核读缓冲区
读取数据到用户传入的buf.

阻塞socket的recv函数执行流程如下:

  1. 等待内核写缓冲区的数据被清空(被TCP协议传送完毕)
  2. 如果协议发送写缓冲区的数据时出现网络错误,返回-1
  3. 如果写缓冲区没有数据了,检查内核读缓冲区
  4. 如果内核读缓冲区没有数据(TCP协议正在接收数据),则等待,直到有数据(TCP协议接收
    完数据)
  5. 如果等待TCP协议接收时网络断了,返回0
  6. 当内核读缓冲区有数据后(TCP协议接收完数据),将内核读缓冲区数据拷贝到buf中,返回
    拷贝的字节数(<=len)
  7. 如果内核读缓冲区的数据没有拷贝完,则需要下一次拷贝
  8. 如果拷贝时出错,返回-1

返回值:

  • >0: 从内核读缓冲区读取的字节数

  • 0: 等待TCP协议接收数据时网络断了,对端关闭连接

  • -1: 分情况,具体的错误查看errno
    阻塞: 网络错误(TCP发送写缓冲区数据出错)
    超时(等待读缓冲区出现数据超时)
    拷贝错误(拷贝读缓冲区数据出错)

    非阻塞: 网络错误(TCP发送写缓冲区数据出错)
    读缓冲区无数据
    拷贝错误(拷贝读缓冲区数据出错)

非阻塞and阻塞:
相同点: 都需要经历写缓冲区被清空的等待过程(如果调用recv时写缓冲区有数据)
不同点:

  1. 阻塞socket: 如果内核读缓冲区没有数据可读,则阻塞,recv不返回,超时后返回-1,
    置errno=EWOULDBLOCK
  2. 非阻塞socket: 如果内核读缓冲区没有数据可读,则立即返回-1并置errno=EWOULDBLOCK

编程注意事项:

  1. 返回0表明对端关闭连接,本端也要关闭连接
  2. 阻塞socket从读缓冲区拷贝数据时,如果一次拷贝不完,怎么办?
    什么叫TCP协议接收完一次数据?如何定义一次接收?
    上一次的还没拷贝完,读缓冲区又出现了下一次传过来的数据,需要区分两次数据吗?how?

1.2 内核写缓冲区:

存储用户程序向内核写入的数据,供内核向外部发送
send(SOCKET fd, const char buf, int len, int flag)*

阻塞socket的send函数执行流程如下:

  1. 比较待发送数据的长度len和内核写缓冲区的长度m
  2. 若len > m,返回-1
  3. 若len <= m, 检查TCP协议是否正在传送写缓冲区的数据
  4. 若正在传送,等待
  5. 若等待时出现网络错误,返回-1
  6. 若传送完毕(写缓冲区可能有数据也可能没有),比较len和剩余空间c
  7. 若len > c,等待TCP将写缓冲区的数据发送干净
  8. 若等待时出现网络错误,返回-1
  9. 若len <=c, 拷贝buf中数据到剩余空间
  10. 若拷贝成功,返回拷贝的字节数(==len)
  11. 拷贝出错,返回-1

内核缓冲区的状态不因send/recv的调用发生改变,而是send/recv调用时内核缓冲区处于
不同的状态下这两个函数的额返回值会不同,使用epoll时,epoll根据内核缓冲区的状态
来发出通知,在某个状态下如果调用recv/send会返回(并不需要真的调用),则发出对应的
通知

返回值:

  • >0: 拷贝到内核写缓冲区的字节数(==len)
  • -1: 分情况
    阻塞:
    a) 网络错误(等待TCP协议传送数据或等待TCP协议清空写缓冲区)
    b) 超时(等待TCP协议清空写缓冲区)
    c) 拷贝错误
    非阻塞:
    a) 网络错误(等待TCP协议传送数据)
    b) len>c,写缓冲区剩余空间不够
    c) 拷贝错误
    当使用send()发送空数据时,如char* buf[1024] = {0},返回0

非阻塞and阻塞socket:
相同点: 都需要比较len和m; 都需要等待TCP协议传送数据(如果调用send时TCP协议正在传送数据)
不同点:

  1. 阻塞socket: 若len>c, 剩余空间不足,需要阻塞等待TCP协议清空写缓冲区,等待超时,返回-1,置errno=EWOULDBLOCK
  2. 非阻塞socket:若len>c, 剩余空间不足,直接返回-1,置errno=EWOULDBLOCK,虽然TCP协议扔需要清空写缓冲区

socket的可读/可写指的是啥?

可读指的是读缓冲区处于该状态时调用recv()能返回,可写指的是写缓冲区处于该状态时
调用send()能返回;

可读可分为3类:

  1. 读缓冲区有数据可读(高于低水位标记)
  2. 对读缓冲区调用recv()时返回-1,errno被置位,出现网络错误(拷贝错误是读的过程出现的)
  3. 读缓冲区通过TCP协议收到对端的SYN或FIN消息,请求建立连接或关闭对端的写(本端的读)
    注意这里的SYN是针对listen套接字,FIN是针对已连接套接字

可写可分为3类:

  1. 写缓冲区剩余空间足够

  2. 对写缓冲区调用send()时返回-1,errno被置位,出现网络错误(拷贝错误是写的过程出现的)

  3. 写缓冲区收到本端将要给对端通过TCP协议发送的FIN信息,请求关闭本端的写;

    注意这个条件的特殊性:
    写意味着主动操作,为了避免本端多次发送FIN,设计了一种机制,当第一次发送FIN后
    (调用了close()),意味着我想关闭本端的写,但只能发送一次,如果你编程错误,
    调用了2次,对不起,下次你发送FIN仍然可以写进写缓冲区,但是本端进程会理解收到
    sigpipe信号关闭进程,太暴力了点了吧?

对socket可读/可写条件的理解:
a) 缓冲区真的有数据可读/可写调用recv()/send()当然要返回,天经地义
b) 对染缓冲区没有数据可读,但是在等待的过程出现了错误,错误必须处理,因此得返回
c) socket不仅要处理数据的读写,还要处理连接的建立和释放,对端请求建立连接or断开
连接or本端发送断开连接的信号后那本端调用recv/send查看读/写缓冲区也要能返回

socket的可读/可写与epoll的EPOLLIN/EPOLLOUT是一个意思吗?

不是一个意思,socket的可读/可写包含的范围更广,socket的可读/可写实际上对应epoll的
各种通知条件(EPOLLIN,EPOLLOUT,EPOLLERR等)的组合,后者的通知条件实际上是对socket
可读/可写的细化,方便编程处理,你说socket可读,我如何才能区分是真的有数据可读还是
返回了错误,我的程序逻辑该怎么设计?

可读(默认阻塞模式):

  1. 缓冲区真的有数据: EPOLLIN
  2. 网络错误: EPOLLERR
  3. 对端SYN: EPOLLIN
    对端FIN: EPOLLRDHUP

非阻塞模式下EPOOIN通知的条件还包括缓冲区没数据的情形,因为此时recv()也返回,
只是返回-1且置errno为EWOULDBLOCK

所以收到EPOLLIN时需要自己判断区分缓冲区有数据(or TCP还在接收)还是收到了SYN,区分的方式:
SYN: 针对监听套接字
真的有数据: 针对已连接套接字
所以可根据socket的状态来判断,是LISTENING还是CONNECTED

可写:

  1. 缓冲区真的由剩余空间: EPOLLOUT
  2. 网络错误: EPOLLERR
  3. 本端FIN: 不用epoll操心,胆敢再发一次FIN,直接杀掉你的进程

非阻塞模式下EPOLLOUT通知的条件还包括缓冲区剩余空间足够的情形,因为此时send()也
发挥,只是返回-1且置errno为EWOULDBLOCK

1.3 为什么要搞出一个非阻塞来?难道阻塞不香吗?

阻塞确实香,因为socket读写本质上是与内核读/写缓冲区打交道,缓冲区里没数据那就
等着呗;
然而在单线程程序中这么干等着程序逻辑就不能处理其他的事情了,多浪费cpu资源,
不如直接返回,告诉调用者现在数据还没准备好,您待会儿再来

1.4 连接建立阶段的阻塞/非阻塞

阻塞模式下调用connect()时发生了什么?
1) 本端通过TCP协议发起SYN给对端
2) 等待对端通过TCP协议回复ACK
3) 如果对端不可达or超时,返回-1,置errno
4) 如果收到ack,返回0,连接成功

为什么想要在非阻塞模式下调用connect()?

阻塞模式需要等待一个RTT的时间,受网络抖动影响大,我设计client时不想干等着,
还想着能在单线程中处理其他的业务逻辑,因此我要求使用非阻塞socket.

注意client端的socket只有一个,既负责建立连接又负责收发数据(访问其持有的读/写
缓冲区),server端的socket有2个,listen_fd和conn_fd,前者负责建立连接,后者负责
收发数据,因为server需要对接好多client,将监听任务拎出来有助于提高并发
listen_fd只负责处理socket可读(其实只有收到SYN);
conn_fd负责处理socket可读(排除掉收到syn的其他可读条件)和可写

因此client端想调用connect()时使用非阻塞socket,必然同时影响client端收发数据
的代码逻辑要按照非阻塞socket的方式编写!!!

非阻塞模式下调用connect()发生了什么?
1) 本端通过TCP协议发送SYN给对端
2) 如果对端回复ACK,返回0,连接成功
3) 如果对端还没回复ACK(刚发送就想收到回复,哪有那么快),返回-1,TCP协议置位errno,
若errno为EINPROGRESS,则表示你再等等,若为其他值,网络出错

应当如何使用非阻塞socket的connect()?
一般情况下调用connect()返回的都是-1(不可能刚发送就收到回复),
1) 判断下errno是否是EINPROGRESS,若是,认为正常,把socket加入epoll监听中去,
否则,网络错误,重新连接
2) EPOLLOUT时,先判断下是否还有错误(通过getsockopt),有错误重连,无错误连接成功

调用listen()发生了什么?
listen只是把一个可以主动连接的套接字变成被动连接的套接字,不涉及与对端socket
的通信,返回值为0表示成功,为-1表示失败(比如绑定了一个已经在监听的端口或系统
保留端口)

如何由监听套接字产生已连接套接字?
1) 监听套接字检测socket可读条件(收到SYN)
2) 收到连接请求,发送ack,将其放入一个队列(半连接队列),因为可能由多个client请求连接,
所以要排队
3) 等待client的ack,收到哪个client的ack,就将半连接队列中的那个client标记对象放入已连接队列
3) 等待已连接队列非空,accept()从中取出交给进程,调用一次,取出一个
4) 若等待超时,返回-1
所以accept阻塞的原因是已连接队列为空

为什么想把监听套接字也变为非阻塞的?
因为想在等待收到client的ack过程中做点其他的

阻塞模式下调用accept()发生了什么?
检查已连接队列,若为空,阻塞,若不为空,返回已连接套接字

非阻塞模式下调用accept()发生了什么?
检查已连接队列,若为空,返回-1,若不为空,返回已连接套接字

应当如何使用非阻塞socket的accept()?
无论阻塞还是非阻塞,都需要使用while循环,while(accept()!=-1),因为accept一次只能
取出一个已连接套接字;
阻塞socket不再循环是因为等待已连接队列非空超时;
非阻塞socket不再循环是因为已连接队列为空;
阻塞listen socket不需要epoll来监听socket可读,因为直到已连接队列非空时才返回
非阻塞listen socket需要epoll来监听socket可读, 因为已连接队列为空也返回,总得
回来再次检查希望能取到已连接套接字,因此需要epoll来帮忙监视listen socket可读,
提供再次检查的契机

2. epoll为代表的多路复用

2.1 为什么要提出多路复用的机制

因为我们想享受非阻塞socket带来的好处,即可以在简单的单线程程序中同时处理socket
读写和其他任务,不至于因阻塞等待在socket读写上而干瞪眼;

但是想法很美好,如果socket数据准备好了,谁来通知我呢?总不能隔一段时间轮询一次吧?

多路复用机制本质上是帮我们代理了对一个socket的内核缓冲区的监控,数据准备好了
就通知用户.但是只为一个socket服务也太浪费了,因此同时代理多个socket,因此称为
多路复用

2.2 epoll实现了哪些功能

  1. 内核缓冲区准备好被用户访问(此时用户访问不用再等待,调用send()/recv()可直接
    返回,但内核缓冲区不一定可读或可写)时通知用户,表现为epoll_wait返回

    内核读缓冲区: 写缓冲区已清空但读缓冲区暂无数据时;
    读缓冲区出现数据时;
    内核写缓冲区: TCP协议没有正在发送数据且写缓冲区剩余空间不够时;
    写缓冲区剩余空间够或其中的数据已被发送干净时;

  2. 通知用户时提醒现在内核缓冲区的状态,表现为EPOLLIN, EPOLLOUT, EPOLLERR, EPOLLRDHUP等

实质上是将对非阻塞socket调用send()/recv()时可能出现的返回值和errno做了分类

EPOLLIN:
a) 读缓冲区有数据,recv返回读取的字节数
b) 读缓冲区无数据,recv返回-1,置errno=EWOULDBLOCK
c) 读缓冲区无数据,recv返回0(对端发送FIN)
d) 读缓冲区收到SYN

收到EPOLLIN后,
i) 若当前socket为监听套接字,调用accept()
ii) 否则,调用ioctlsocket查看接收的数据量是否为0,若为0,close socket
iii) 否则,调用recv()读数据

EPOLLOUT:
a) 写缓冲区剩余空间足够或已被发送干净,send返回写入的字节数
b) 写缓冲区剩余空间不足,send返回-1,置errno=EWOULDBLOCK
c) 已发送syn

收到EPOLLOUT后,
i) 若当前socket还未变为connected(调用connect,已发送Syn还不确定是否收到ack),
调用getsockopt看是否有错误,是,close socket,否则正常,更改状态为connected
ii) 若当前socket已连接,调用send()发数据

EPOLLERR: 排除掉EWOULDBLOCK和拷贝错误的剩下的唯一一种错误:网络错误

收到EPOLLERR后,解除注册,close socket

EPOLLERR只能知道本端的内核缓冲区报告出错了,且是网络错误,
不知道出错的原因是否是对端异常断开(不发通知,如FIN就断掉)

如果对端异常断开导致了网络错误,本端是没有办法通过EPOLLERR感知的,
只能通过本端再执行一步 写/读(读之前会清空写缓冲区)报错,然后才
发现对端异常关闭连接,

这需要server主动去向client发数据来检测client是否存活,不必要,
一个替代策略是检测超时,client超时未发出请求server就主动断开连接

EPOLLRDHUP: 对面正常关闭连接(close),此时recv()返回0,其实是将EPOLLIN的©
单独拎出来,本端需要取消注册socket,close socket

收到EPOLLRDHUP后,解除注册,close socket

如果不支持EPOLLRDHUP, 则收到EPOLLIN通知后,
i) 检测调用recv是否返回0
ii) 若返回0,从epoll中删除socket的注册,并close socket

3) 为什么epoll搞出了LT和ET两种模式?

为了省事.

epoll本质上就是监控socket的内核缓冲区状态的监控器;

LT模式下t1时刻读缓冲区有数据可读,发出EPOLLIN通知,用户调用recv()读了,没读完,
t2时刻读缓冲区剩下的数据还在,再次发出EPOLLIN通知,用户继续读

你说数据量大,只能多次读,但是两次读需要epoll来两次触发,在两次触发之间还处理
了其他socket的读写,多耽误时间,为啥不一次读完?

好好好,那我调用while循环一直读直到recv返回-1(说明数据读完了或读取出错)总
可以了吧

那为什么还需要epoll的LT模式多次触发?你都给我保证了每次EPOLLIN通知你都读完,
那就砍掉多次触发,改成ET模式下的一次触发吧,当非阻塞socket下调用recv()从等待
到可以返回的时刻发出EPOLLIN通知,此后即使是你的锅,因为疏忽没有把数据读完,
读缓冲区还有数据也不再触发

对写缓冲区,ET模式下如果一次send()后写缓冲区的剩余空间还够再来几次写,而你
只写一次,那下次再想写时也不会有EPOLLOUT通知,因为ET模式下只在调用send()
从等待到可以返回的时刻发出EPOLLOUT通知,因此一次写必须得把写缓冲区的剩余
空间给写得不够用了才行,得调用while循环写

LT/ET模式对读写逻辑有什么要求?
读: 关心的是我能否把缓冲区的数据全部读完
写: 关心的时我能否把应用层要发的一条数据一次性写入缓冲区

 a) LT模式-读: 可以只recv一下,等待EPOLLIN再次通知时再读取,缺点是浪费时间    
               也可以在while循环中读直到recv返回-1,这样保证只通知一次就     
               把读缓冲区的数据全部读完                                     
 b) ET模式-读: 只能在while循环中读直到recv返回-1,这样保证只通知一次就把     
               读缓冲区的数据全部读完                                       
 c) LT模式-写: 只send一下,等EPOLLOUT再次通知时写入                          
               也可以在while循环中写直到send返回-1,但不保证能把想发的数据   
               一次发完,只能保证把写缓冲区写完,尽可能的发,下次通知时再发    
 d) ET模式-写: 只能在while循环中写直到send返回-1,但不保证把想发的数据一次   
               发完,只能保证把写缓冲区写完,尽可能的发,下次通知时用户再发

3. TCP socket如何保证应用层消息的完整性

自己写程序打包,解包,定义一个消息的结束标识符,考虑protobuf?

TCP是基于流的协议,不记录用户发送的间隔,也无法感知哪些字节是属于用户定义的同一条
消息,只是将消息看成字节流,对流中的每个字节编号,保证接收端与发送端的字节流顺序
一致

4. 阻塞/非阻塞, epoll, LT/ET, 单线程/多线程 到底该怎么搭配使用?

  1. 阻塞-多线程:
    以读缓冲区为例,缓冲区无数据时,等待,直到有数据开始读,其他操作都不能进行
    如果想避免对其他操作的影响,可将读单独放在一个线程

  2. 非阻塞-epoll-单线程-LT
    非阻塞当一次没能读成功时(内核读缓冲区还没数据),需要有一个代理来检测内核
    读缓冲区的状态,下次可返回时通知用户,因此使用epoll,这样epoll通知用户,用户
    执行读逻辑,如果读成功,就处理读取的数据,如果读取不成功,代码也可往下执行
    处理其他事情,不会因为读取数据阻塞而不能继续往下执行代码;
    epoll监测读缓冲区的状态触发方式可选水平触发

    主要缺点:
    没有数据可读(返回-1置errno=EWOULDBLOCK)也会触发EPOLLIN,频繁触发占用cpu资源,
    与轮训差不多,浪费cpu资源

  3. 非阻塞-epoll-单线程-ET
    epoll监测读缓冲区的状态触发方式可选边沿触发

5. client和server通过socket通信应该怎样设计?

5.1 client的读写模型

client在请求-响应模式中处于主动地位,主动发送请求,且发送时间由自己决定;      
发送请求的获取可以是终端的stdin或android/ios app提供的接口等;               
  1. client需要处理3个文件描述符: stdin, stdout, socket;
  2. 包含stdin - socket send, socket recv - stdout两个流程,要保证这两个流程不能
    相互干扰,即从标准输入读入来向server发消息时不能阻塞从server收消息,从server收
    消息也不能阻塞从标准输入写消息;
  3. stdin一定是阻塞的,等待用户输入消息,即标准输入缓冲区为空时,必须等待,等其出现
    数据后,才能读取并通过socket send
  4. socket可以是阻塞的,也可以是非阻塞的,非阻塞模式下必须有额外的监控机制,如
    select/poll/epoll
  5. 分离stdin和socket recv有2种方式:
    a) 使用多路复用 select/poll/epoll, 设置socket非阻塞, 同时监控stdin和socket
    可读,可写,注意socket的可写包含connect连接成功的判断
    b) 使用多路复用 select/poll/epoll, 设置socket阻塞,同时监控stdin, socket可读,
    无需监控socket可写,因为socket写缓冲区空间不够时会阻塞,相比于非阻塞socket
    响应会慢一些,因为虽然多路复用可以同时监控stdin和socket可读,但执行时毕竟
    是在一个线程中顺序处理,如先处理stdin就绪,再处理socket可读就绪,但处理stdin
    就绪时如果socket写缓冲区空间不够,需要等待,等待这段时间是没有办法继续处理
    socket可读事件的,所以不建议此种方式
    c) 使用2个进程分别处理stdin-socket send和socket recv-stdout,实现真正的分离,
    这样socket可以且只能设置成阻塞模式
    由于两个流程没有共享资源,所以无需使用双线程,
    编程简单,效果好,推荐使用
    使用多路复用分离时在main函数中虽然与server端的类似,都是启动dispatcher后死循环
    运行,好像没有给send留下位置,但send是包含在stdin的监听中了,程序会自动监测stdin
    的输入,一旦有输入,就进入程序处理逻辑

5.2 server的读写模型

server在请求-响应模式中处于被动地位,被动接收请求,处理后发出响应,即使不处理
只转发,充当了类似client的角色,但仍是接收请求,转发请求,收到响应,转发响应,
不改变其被动角色的本质,因此对于混合了client和server两种角色的server,应当
使用server的被动读写模型,而非client的主动读写模型

  1. server只需要处理一种但很多个socket文件描述符,即并发
  2. reactor模式使用多路复用,基于事件驱动,统一所有socket的读写在一个线程中完成,
    编程简单,避免了多线程同时处理读写和计算带来的编程难度
  3. 单线程模式下需要顺序处理每个就绪的socket,虽然使用非阻塞避免了读写阻塞对其他
    socket的影响,但计算时间仍不可避免
  4. 为加快计算速度,分离计算和读写,建立线程池统一处理所有socket的请求,请求排队,
    处理后得到的响应排队,所有读写任务由主线程完成
  5. 不同socket其读写需求不同,对读写进一步拆分,主线程仅仅accept创建连接套接字,
    根据读写需求将连接套接字归类,形成若干个subreactor处理读写,原有的计算线程池
    不变
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章