socket学习之TCP协议过程(一)

2019.7.3更新:
关于开篇提出的问题,我基本有点弄明白了,如果有错还烦请各位大牛指出。之所以我会遇到recv跳不出的问题,是因为我所发送的数据量比较小,并达不到1024的长度,所以缓冲区会一直等待长度满,才会认为data为空。另外就是服务器程序里的sleep是很有必要的,当数据量较小的时候,很有效的避免了粘包现象。

前段时间学习了socket编程,把现有的分析系统分成了服务器端与客户端两部分。但基本使用的还是简单的socket的编程,对于内里还不够了解,只能说会用了而已。
使用过程中我发现了一个问题,一直想不通原因,也借此机会仔细学一下socket原理,希望学到最后可以解决我的困惑吧。先把问题阐述一下:
代码为廖雪峰老师的教程中的代码,服务器端:server.py

import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('127.0.0.1', 9999))
s.listen(5)
print('Waiting for connection...')
while True:
    # 接受一个新连接:
    sock, addr = s.accept()
    # 创建新线程来处理TCP连接:
    t = threading.Thread(target=tcplink, args=(sock, addr))
    t.start()

def tcplink(sock, addr):
    print('Accept new connection from %s:%s...' % addr)
    sock.send(b'Welcome!')
    while True:
        data = sock.recv(1024)
        time.sleep(1)
        if not data or data.decode('utf-8') == 'exit':
            break
        sock.send(('Hello, %s!' % data.decode('utf-8')).encode('utf-8'))
    sock.close()
    print('Connection from %s:%s closed.' % addr)

客户端:client.py

# 导入socket库:
import socket

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 建立连接:
s.connect(('127.0.0.1', 9999))
# 接收欢迎消息:
print(s.recv(1024).decode('utf-8'))
for data in [b'Michael', b'Tracy', b'Sarah']:
    # 发送数据:
    s.send(data)
    print(s.recv(1024).decode('utf-8'))

到这之前都没有问题,问题出在接下来:

s.send(b'exit') # 假设不加上这行,服务器端就会一直阻塞,不跳转
s.close() # 如果直接调用close也可以跳出循环

根据服务器端的逻辑,当接收不到新数据时,就应该跳出循环,对收到的请求进行处理。但现在的情况是如果不关闭连接或是不发送退出指令,服务器就不会跳出循环。
这个问题我自己想了很久,我初步的理解是因为socket的阻塞机制会一直等待当前连接的全部数据,直到对方关闭了连接。但是这样的话就不符合服务器多连接的初衷了呀……在廖老师的教程中是收到一个连接请求就开一个新线程进行处理,但我的程序中是接收到数据后再单开一个线程处理数据内容,这两者会在这个问题上产生区别吗。
好了问题全部描述完毕,进入正文,socket在tcp连接中到底的怎么工作的呢?
以下内容很多借鉴于https://www.cnblogs.com/f-ck-need-u/p/7623252.html,这位大佬写的真的很清晰!

背景知识补充

  1. socket有两个维护的缓冲区:send_buffer和recv_buffer
    通过TCP连接发送的内容会先拷贝到send_buffer,可能是由用户进程缓冲区app buffer拷贝过去的,也可能是由内核kernel buffer拷贝过去的,send()函数充当一个写入数据的角色,可以由write()代替,因此send_buffer也称为写入缓冲区。但是send()函数要比write()有效率很多。
    数据是通过网卡发送给另一端的,因此存在send buffer中的数据需要拷贝到网卡上,这样一来不需要经过CPU,直接采用DMA(直接内存存取)的方式将数据拷贝到网卡上即可。
    从发送端的网卡传送到接收端的网卡后,同样采取DMA的方式,将数据拷贝到recv buffer上,而后再从recv buffer中拷贝到用户进程缓冲区app buffer上即可。
  2. socket五元组 protocol,src_addr,src_port,dest_addr,dest_port
    连接方式(TCP、UDP);源地址;源端口;目标地址;目标端口
  3. 两种socket:已连接socket与监听socket
    配置文件先给出要监听的端口和ip,然后通过bind()函数将这个监听socket绑定到这个端口上,再调用listen()函数对该端口进行监听。当某个客户端发送了连接请求后,服务器与之建立三次握手,调用accept()函数后,会返回一个新的套接字,即已连接套接字,此后二者之间的通信都由这个accept产生的套接字进行,就算此时关闭了监听socket也一样可以继续通信,前提是存在已连接socket。

TCP全过程

socket()

生成套接字

bind()

绑定端口IP。
若想实现多端口多IP多套接字监听,可以多次调用socket() bind()实现。

listen()

对已绑定的端口进行监听,调用后监听socket的状态会由CLOSED变为LISTEN
如果想监听多个套接字,会采用select()或者poll()的方式进行轮询,查看套接字是否被申请连接;若只监听一个套接字,也一样是同样的轮询方式。

connect()

申请连接,此时端口状态当然必须是LISTEN,客户端在发起请求前也要生成一个socket,通常不对端口号进行绑定,直接随机端口号。
当建立连接时,如果时select()/poll()方式轮询,则在监听过程中阻塞,当发现有数据(SYN)写入到recv buffer中时,内核被唤醒(整个三次握手四次挥手都在内核中进行,不涉及到用户app),将SYN数据拷贝到kernel buffer中,确认SYN数据是否合理等,准备一个SYN+ACK数据,由kernel buffer拷贝到send buffer中,再由send buffer拷贝到网卡然后发送,这时会在连接未完成队列(syn queue)中为该连接创建一个新项目,状态为SYN_RECV。
而后select()/poll()继续轮询,若发现写入到recv buffer的数据为ACK,内核再次被唤醒,将连接未完成中的项目移到连接已完成项目(accept queue/established queue)中设置状态为establish,若accept()被调用,则项目会被删除,表示连接已建立;
若发现写入的数据为SYN,则重复前述过程,在连接未完成队列中建立新项目。
若连接未完成队列满了或连接已完成队列满了,系统会阻塞,这个参数由listen()中制定,这两个队列的参数是同一个,实际上可以认为这两个是同一个队列的不同状态。

Recv-Q & Send-Q

netstat命令的Send-Q和Recv-Q列表示的就是socket buffer相关的内容
对于监听状态的套接字,Recv-Q表示的是当前syn backlog,即堆积的syn消息的个数,也即未完成队列中当前的连接个数,Send-Q表示的是syn backlog的最大值,即未完成连接队列的最大连接限制个数;
对于已经建立的tcp连接,Recv-Q列表示的是recv buffer中还未被用户进程拷贝走的数据大小,Send-Q列表示的是远程主机还未返回ACK消息的数据大小。
之所以区分已建立TCP连接的套接字和监听状态的套接字,就是因为这两种状态的套接字采用不同的socket buffer,其中监听套接字更注重队列的长度,而已建立TCP连接的套接字更注重收、发的数据大小。

accept()

accpet()函数的作用是读取已完成连接队列中的第一项(读完就从队列中移除),并对此项生成一个用于后续连接的套接字描述符,假设使用connfd来表示。有了新的连接套接字,工作进程/线程(称其为工作者)就可以通过这个连接套接字和客户端进行数据传输,而前文所说的监听套接字(sockfd)则仍然被监听者监听。
TODO preforker模式与worker模式event模式的区别
常听到同步连接和异步连接的概念,它们到底是怎么区分的?同步连接的意思是,从监听者监听到某个客户端发送的SYN数据开始,它必须一直等待直到建立连接套接字、并和客户端数据交互结束,在和这个客户端的连接关闭之前,中间不会接收任何其他客户端的连接请求。细致一点解释,那就是同步连接时需要保证socket buffer和app buffer数据保持一致。通常以同步连接的方式处理时,监听者和工作者是同一个进程,例如httpd的prefork模型。而异步连接则可以在建立连接和数据交互的任何一个阶段接收、处理其他连接请求。通常,监听者和工作者不是同一个进程时使用异步连接的方式,例如httpd的event模型,尽管worker模型中监听者和工作者分开了,但是仍采用同步连接,监听者将连接请求接入并创建了连接套接字后,立即交给工作线程,工作线程处理的过程中一直只服务于该客户端直到连接断开,而event模式的异步也仅仅是在工作线程处理特殊的连接(如处于长连接状态的连接)时,可以将它交给监听线程保管而已,对于正常的连接,它仍等价于同步连接的方式,因此httpd的event所谓异步,其实是伪异步。通俗而不严谨地说,同步连接是一个进程/线程处理一个连接,异步连接是一个进程/线程处理多个连接。

send() & recv()

send()将send buffer中的数据拷贝到kernel buffer中,recv()将中recv buffer的数据拷贝到app buffer中。

close() & shutdown()

close()函数指关闭一个套接字描述符,包含网络套接字,当close()被调用后,会尝试发送send buffer中的数据,但只是对该连接的引用计数-1,只有计数减为零时,才会关闭该套接字进入四次挥手过程。要注意如果是父子进程共享同一个套接字,那么关闭子进程套接字很可能不会使套接字关闭,当父进程也关闭后才会关闭。
shutdown()函数是专门针对网络套接字关闭的,它不再进行计数,直接掐断该套接字的所有连接,进入四次挥手。这个函数存在三种关闭方式:

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

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

学到目前为止,当然可以解释直接调用close()就可以跳出循环的原因了,但我仍然不理解send和recv每次复制的值到底是什么,或者说缓存的值每次是怎么复制的,如果数据传输完毕了为什么不会跳出循环。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值