python之socket编程

C/S 和 B/S

C/S 架构 需要客户端下载对应的客户端软件,如手机的各种app等。
B/S 架构 只需要输入网址即可和服务端交互。
现如今,B/S 越来越热,很大一部分原因就是使用是简便,用户不需要下载各种客户端软件。试想一下,如果想使用百度还得下载百度客户端,使用谷歌就下载谷歌客户端,那么用户体验就不好。用一个浏览器,输入不同网址和不同服务端交互,统一入口。
例如现在微信程序内部绑定各种app和公众号,用户就不需要在手机下载各种app

OSI 模型

TCP的三次握手和四次挥手

为什么是三次握手而不是两次握手

首先,明确一点,我们研究的是TCP网络通信,TCP需要建立连接,也就是确保通信线路是畅通的。那么怎么保证通信线路是畅通的呢?先发几个消息试探一下这条道路是不是通顺的,如果探子折在半路,那么连接就无法建立,这就是tcp为什么要握手的原因
下面来谈一谈为啥是三次握手而不是两次握手?
若建立连接只需两次握手,客户端并没有太大的变化,在获得服务端的应答后进入ESTABLISHED状态,即确认自己的发送和接受信息的功能正常.
但如果服务端在收到连接请求后就进入ESTABLISHED状态,不能保证客户端能收到自己的信息(因为客户端没给我回确认,我不知道我发的消息是不是在某个网络设备中被丢弃了)。考虑网络拥塞的情况,客户端发送的连接请求迟迟到不了服务端,客户端便超时重发请求,如果是两次握手,那么服务端正确接收并确认应答,双方便开始通信,通信结束后释放连接。 此时,如果那个失效的连接请求抵达了服务端,由于只有两次握手,服务端收到请求就会立即进入ESTABLISHED状态,等待发送数据或主动发送数据。但此时的客户端早已进入CLOSED状态,服务端将会一直等待下去,这样浪费服务端连接资源.

为什么是四次挥手

TCP连接是双向的,因此在四次挥手中,前两次挥手用于断开一个方向的连接,后两次挥手用于断开另一方向的连接.

2MSL

MSL 是一个包在网络上生存的最大时间。
先发送FIN的一端,就会发四次挥手的最后一个ACK.考虑这样一个场景,如果ACK没有正确到达,那么另一端迟迟没收到确认报文,那么会重发FIN报文,这个报文最迟MSL的时间就能到达对端。所以,只要对端等2MSL的时间,在这段时间没收到任何FIN报文,说明ACK正确发送过去了。这就是为啥先发FIN的一端会等待2MSL的时间才变成closed的状态。如果服务端先close(crtl+c终止服务端测试),那么服务端需要等2MSL(通常是2到4分钟)才会释放端口,所以此时立即开启服务端,会报下面错误

解决办法:

#加入一条socket配置,重用ip和端口

phone=socket(AF_INET,SOCK_STREAM)
phone.setsockopt(SOL_SOCKET,SO_REUSEADDR,1)  # 在bind前加
phone.bind(('127.0.0.1',8080))

如果是客户端先close,无关紧要,因为客户端的端口是不固定随机分配的

建立连接到底是啥意思

所谓的建立连接,只是一种抽象的说法,服务端和客户端通过几番确认,认为对方一直会在。而如果确认了对方存在,那么就会为以后的对话通讯分配内存、CPU处理时间等资源,每个设备都会在本地去维持这么一个状态,来告诉自己是有一个连接的,这些设备所花的资源和维护的状态,就是连接。而整个网络是不会记录有着一条连接的,所以说连接只是记录在各个设备的一个状态信息。
那么,到现在我们知道了,连接其实并不是所谓的有一根电线连起两个设备,而是两方确认了一下对方的存在后,自己在本地记录的状态。

为什么服务器都有连接数量的限制?

这里只做讨论。我认为是有两点:
物理带宽的限制,决定了一个时间段内发起连接的数据包不会超过某个数,造成了设备的链接数量的限制。
维持连接需要分配内存等资源,设备的资源有限,决定了一定有个最大连接数的极限。

socket层

socket编程第一步

server.py

import socket

sock = socket.socket()
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('127.0.0.1', 9001))
sock.listen(5)

while 1:
    conn, addr = sock.accept()
    while 1:
        # 这个try是为了捕捉windows pycharm里面关闭客户端出现错误
        try:
            # 服务端一般不主动发消息,而是等待客户端发消息(虽然可以发,但是一般的服务端-客户端模型不会这样做)
            data = conn.recv(1024)
            # 客户端调用close,那么data 就是 ''
            if not data:
                break
            conn.sendall(data.upper())
        except ConnectionResetError:
            break
    conn.close()

sock.close()

client.py

import socket

sk = socket.socket()
sk.connect(('127.0.0.1', 9001))
while 1:
    msg = input('>>: ')
    if not msg:
        continue
    if msg == 'q':
        break
    sk.sendall(msg.encode('utf8'))
    sk.recv(1024)

sk.close()

编程第二步:解决粘包

粘包是应用层的数据混在一起了,在另一端的应用层无法分割出来
粘包一般是由于两种情况出现的:

  1. 客户端应用层发送的时候数据小且间隔小,tcp协议会当成一个包去发送
  2. 服务端收数据的时候,第一次没收完,第二次收的时候会受到第一次没收完的数据包。

解决这个问题的根源在于客户端认为要作为一个整体的信息发送的时候,应该把这个信息的长度告诉服务端,那么服务端在经过下面四层的解包之后在应用层层面就能决定接收多大的数据了。所以,如果客户端要连续send的两次数据作为两个整体让服务端知道,那么需要把这两个数据的长度分别告知服务端
注意:虽然客户端把应用层的几个数据封装成一个包发送过去,但是服务端在经过包括tcp协议在内的四层协议的层层解析到应用层就是客户端要发送的那几个数据了,只不过服务端不知道这一堆数据的分割边界

解决粘包比较low的办法:

  1. 在每次send数据的时候先send长度,而且send长度不能和send数据连在一起,所以客户端需要在两次send之间recv一次,但是程序的运行速度远快于网络传输速度,所以在发送一段字节前,先用send去发送该字节流长度,这种方式会放大网络延迟带来的性能损耗
    2.每一次send之后要想继续send,sleep一段时间让操作系统把缓冲区的数据先发过去,但是sleep的时间是不确定的

struct模块可以打包数据成固定长度,可以在和数据连续一次send,走一次网络延迟,服务端收固定长度的数据拿到真实数据的长度。为字节流加上自定义固定长度报头,报头中包含字节流长度,然后一次send到对端,对端在接收时,先从缓存中取出定长的报头,然后再取真实数据

server.py

#!/usr/bin/env python
# -*- coding:utf-8 -*-

import socket
import subprocess
import struct

sock = socket.socket()
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind(('127.0.0.1', 9001))
sock.listen(5)

while 1:
    conn, addr = sock.accept()
    while 1:
        # 这个try是为了捕捉windows pycharm里面关闭客户端出现错误
        try:
            data = conn.recv(1024)
            # 客户端调用close,那么data 就是 ''
            if not data:
                break
            ret = subprocess.Popen(data.decode('utf8'), shell=True,
                                   stdout=subprocess.PIPE, stderr=subprocess.PIPE)
            err_msg = ret.stderr.read()
            if err_msg:
                msg = err_msg
            else:
                msg = ret.stdout.read()
            print(msg)
            conn.sendall(struct.pack('i', len(msg)))
            conn.sendall(msg)
        except ConnectionResetError:
            break
    conn.close()

sock.close()

client.py

#!/usr/bin/env python
# -*- coding:utf-8 -*-
import socket
import struct

sk = socket.socket()
sk.connect(('127.0.0.1', 9001))
BUFFER_SIZE = 1024
while 1:
    msg = input('>>: ')
    if not msg:
        continue
    if msg == 'q':
        break
    sk.sendall(msg.encode('utf8'))
    l = sk.recv(4)
    msg_length = struct.unpack('i',l)[0]
    msg_bytes = b''
    msg_bytes_length = 0
    while msg_bytes_length < msg_length:
        msg_bytes += sk.recv(BUFFER_SIZE)
        msg_bytes_length += BUFFER_SIZE
    print(str(msg_bytes, encoding='gbk'))

sk.close()

当然,上面在应用层给我们想发送的数据加了一个头,这个头用来说明真实数据的长度。除此之外,还能定义复制的头,把原来的两层换成三层,加一层说明真实数据的信息,这个信息不是单纯的字符串,可以是序列化字典后的字符串。这样第二层也相当于是真实数据了。在这个"真实"数据的字典里存放了真实数据的长度。

发送时:

先发报头长度
再编码报头内容然后发送
最后发真实内容

接收时:

先手报头长度,用struct取出来
根据取出的长度收取报头内容,然后解码,反序列化
从反序列化的结果中取出待取数据的详细信息,然后去取真实的数据内容

mport json,struct
#假设通过客户端上传1T:1073741824000的文件a.txt

#为避免粘包,必须自定制报头
header={'file_size':1073741824000,'file_name':'a.txt','md5':'8f6fbf8347faa4924a76856701edb0f3'} #1T数据,文件路径和md5值

#为了该报头能传送,需要序列化并且转为bytes
head_bytes=bytes(json.dumps(header),encoding='utf-8') #序列化并转成bytes,用于传输

#为了让客户端知道报头的长度,用struck将报头长度这个数字转成固定长度:4个字节
head_len_bytes=struct.pack('i',len(head_bytes)) #这4个字节里只包含了一个数字,该数字是报头的长度

#客户端开始发送
conn.send(head_len_bytes) #先发报头的长度,4个bytes
conn.send(head_bytes) #再发报头的字节格式
conn.sendall(文件内容) #然后发真实内容的字节格式

#服务端开始接收
head_len_bytes=s.recv(4) #先收报头4个bytes,得到报头长度的字节格式
x=struct.unpack('i',head_len_bytes)[0] #提取报头的长度

head_bytes=s.recv(x) #按照报头长度x,收取报头的bytes格式
header=json.loads(json.dumps(header)) #提取报头

#最后根据报头的内容提取真实的数据,比如
real_data_len=s.recv(header['file_size'])
s.recv(real_data_len)

udp

udp的server端必须先recvfrom
server.py

import socket
ip_port=('127.0.0.1',9001)
udp_server_sock=socket.socket(socket.AF_INET,socket.SOCK_DGRAM) 
udp_server_sock.bind(ip_port)

while True:
    qq_msg,addr=udp_server_sock.recvfrom(1024)
    print('来自[%s:%s]的一条消息:\033[1;44m%s\033[0m' %(addr[0],addr[1],qq_msg.decode('utf-8')))
    back_msg=input('回复消息: ').strip()

    udp_server_sock.sendto(back_msg.encode('utf-8'),addr)

client.py

import socket

sk = socket.socket(type=socket.SOCK_DGRAM)
addr = ('127.0.0.1', 9001)
while 1:
    msg = input('>>: ')
    if msg == 'q':
        break
    sk.sendto(msg.encode('utf8'), addr)
    print(sk.recvfrom(1024))

sk.close()

udp发送的时候在应用层必须带上对端地址,而tcp不需要,因为tcp之前已经建立了连接,操作系统会自动帮忙在包里加上对端地址。udp的sendto不能发送太大的数据,否则会报错。
udp和tcp不同,udp是以消息为单位的,如果只发送3字节数据,接收端仅接受2个字节,在linux中拿到的就是两个字节,下次在收的时候前一次没收完毕的数据也就丢掉了,在windows中则使用报错机制来提醒程序员。

socketserver

内部使用 IO多路复用 以及 “多线程” 和 “多进程” ,从而实现并发处理多个客户端请求的Socket服务端。即:每个客户端请求连接到服务器时,Socket服务端都会在服务器是创建一个“线程”或者“进程” 专门负责处理当前客户端的所有请求。

socketserver模块中分两大类:server类(解决链接问题)和request类(解决通信问题)

在socketserver中,链接循环由模块内部帮我们维护,我们只需要维护自己代码逻的通信循环。
基本使用如下:

import socketserver

class MyServer(socketserver.BaseRequestHandler):

    def handle(self):
        # print self.request,self.client_address,self.server
        conn = self.request
        conn.sendall('welcome ')
        Flag = True
        while Flag:
            data = conn.recv(1024)
            conn.sendall(data.upper())

if __name__ == '__main__':
    server = socketserver.ThreadingTCPServer(('127.0.0.1',8009),MyServer)
    server.serve_forever()

源码流程:

  1. 先找类ThreadingTCPServer的__init__,在TCPServer中找到,创建服务端Socket对象并绑定 IP 和 端口
  2. 在TCPServer的__init__里面又执行 BaseServer.init 方法,将自定义的继承自SocketServer.BaseRequestHandler 的类 MyRequestHandle赋值给 self.RequestHandlerClass
  3. 执行 BaseServer.server_forever 方法,While 循环一直监听是否有客户端请求到达
  4. 有请求进来进而执行self._handle_request_noblock(),该方法同样是在BaseServer中
  5. 执行self._handle_request_noblock()进而执行request, client_address = self.get_request()(就是TCPServer中的self.socket.accept()),然后执行self.process_request(request, client_address)
  6. 在ThreadingMixIn中找到process_request,开启多线程应对并发,进而执行process_request_thread,执行执行 BaseServer.finish_request 方法,里面执行 self.RequestHandlerClass(request, client_address, self)
  7. Mixin类,表示混入(mix-in),它告诉别人,这个类是作为功能添加到子类中,而不是作为父类,它的作用同Java中的接口。
class BaseRequestHandler:
    def __init__(self, request, client_address, server):
        self.request = request
        self.client_address = client_address
        self.server = server
        self.setup()
        try:
            self.handle()
        finally:
            self.finish()

    def setup(self):
        pass

    def handle(self):
        pass

    def finish(self):
        pass

基于tcp的socketserver我们自己定义的类中属性:

  1. self.server即套接字对象
  2. self.request即一个链接
  3. self.client_address即客户端地址
    基于udp的socketserver我们自己定义的类中的属性:
  4. self.request是一个元组(第一个元素是客户端发来的数据,第二部分是服务端的udp套接字对象),如(b'adsf', <socket.socket fd=200, family=AddressFamily.AF_INET, type=SocketKind.SOCK_DGRAM, proto=0, laddr=('127.0.0.1', 8080)>)
  5. self.client_address即客户端地址

总结

  1. tcp是可靠传输,udp是不可靠传输:tcp在数据传输时,发送端先把数据发送到自己的缓存中,然后协议控制将缓存中的数据发往对端,对端返回一个ack=1,发送端则清理缓存中的数据,对端返回ack=0,则重新发送数据,所以tcp是可靠的,而udp仅仅是发送,至于数据包在走网络上各种网络设备的时候是否被丢弃我不管,而且我发送完就会把缓冲区的数据删掉
  2. recv里指定的1024意思是从缓存里一次拿出1024个字节的数据:send的字节流是先放入己端缓存,然后由协议控制将缓存内容发往对端,如果待发送的字节流大小大于缓存剩余空间,那么数据丢失,用sendall就会循环调用send,数据不会丢失。
  3. 网络的收发缓存区:在内存中开辟区域,用于发送和接受的缓冲 作用:协调数据的收发(接受和处理)速度; 减少和磁盘的交互
  4. connect 和 accept 是建立三次握手的过程,close是发FIN报文
  5. TCP的十种状态:只有双方都是established才能收发消息
  6. tcp是基于数据流的,于是收发的消息不能为空,这就需要在客户端和服务端都添加空消息的处理机制,防止程序卡住,而udp是基于数据报的,即便是你输入的是空内容(直接回车),那也不是空消息,udp协议会帮你封装上消息头
  7. 用多线程编写socket的时候,主线程得到的conn在传给子线程(传的是引用)完毕之后不能在下面立即关闭,否则子线程拿到的conn就不能收发数据了,但是多进程就可以,因为多进程会拷贝数据
  8. 多进程遵循COW,写时拷贝,也就是说子进程不是单纯地一股脑拷贝父进程的所有数据,能共用就共用,你父进程要修改的时候,这时就实在不能共用了,这才进行复制
  9. socket做文件传输的时候,应该边读边写,否则占用内存可能过大。
posted @ 2018-09-03 14:20  龙云飞谷  阅读(307)  评论(0编辑  收藏  举报