Python进阶9

Python socket编程

引言

sockets的历史悠久,它们最早在 1971 年的 APPANET 中使用,后来成为1983年发布的Berkeley Software Distribution(BSD)操作系统中的API,称为Berkeley sockets

Web服务器和浏览器并不是使用sockets的唯一程序,各种规模和类型的客户端 - 服务器(client - server)应用程序也得到了广泛使用。

今天,尽管socket API使用的底层协议已经发展多年,而且已经有新协议出现,但是底层 API 仍然保持不变。

最常见的套接字应用程序类型是客户端 - 服务器(client - server)应用程序,其中一方充当服务器并等待来自客户端的连接。

Socket API介绍

Python中的socket模块提供了一个到Berkeley sockets API的接口,其中的主要接口函数如下:

  • socket()
  • bind()
  • listen()
  • accept()
  • connect()
  • connect_ex()
  • send()
  • recv()
  • close()

这些方便使用的接口函数和系统底层的功能调用相一致。

TCP Sockets

我们准备构建一个基于 TCP 协议的socket对象,为什么使用 TCP 呢,因为:

  • 可靠性:如果在传输过程中因为网络原因导致数据包丢失,会有相关机制检测到并且进行重新传输
  • 按序到达:一方发送到另一方的数据包是按发送顺序被接收的。

对比之下,UDP 协议是不提供这些保证的,但是它的响应效率更高,资源消耗更少。

TCP 协议并不需要我们自己去实现,在底层都已经实现好了,我们只需要使用Pythonsocket模块,进行协议指定就可以了。socket.SOCK_STREAM表示使用 TCP 协议,socket.SOCK_DGRAM表示使用 UDP 协议

我们来看看基于 TCP 协议socket的 API 调用和数据传送流程图,右边的一列是服务器端(server),左边的一列是客户端(client)。

image

要实现左边的处于监听状态的server,我们需要按照顺序调用这样几个函数:

  • socket(): 创建一个socket对象
  • bind(): 关联对应 ip 地址和端口号
  • listen(): 允许对象接收其他socket的连接
  • accept(): 接收其他socket的连接,返回一个元组(conn, addr),conn 是一个新的socket对象,代表这个连接,addr 是连接端的地址信息。

client调用connect()时,会通过 TCP 的三次握手,建立连接。当client连接到server时,server会调用accept()完成这次连接。

双方通过send()recv()来接收和发送数据,最后通过close()来关闭这次连接,释放资源。一般server端是不关闭的,会继续等待其他的连接。

Echo Client and Server

刚才我们弄清楚了serverclient使用socket进行通信的过程,我们现在要自己进行一个简单的也是经典的实现:server复述从client接收的信息。

Echo Server

import socket

HOST = '127.0.0.1'  # Standard loopback interface address (localhost)
PORT = 65431       # Port to listen on (non-privileged ports are > 1023)

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.bind((HOST, PORT))
    s.listen()
    conn, addr = s.accept()
    with conn:
        print('Connected by', addr)
        while True:
            data = conn.recv(1024)
            if not data:
                break
            conn.sendall(data)

socket.socket()创建了一个socket对象,它实现了上下文管理器协议,我们直接用 with 语句进行创建即可,而且最后不需要调用close()函数。

socket()中的两个参数指明了连接需要的 ip地址类型传输协议类型,socket.AF_INET 表示使用 IPv4的地址进行连接,socket.SOCK_STREAM 表示使用 TCP 协议进行数据的传输。

bind()用来将socket对象和特定的网络对象和端口号进行关联,函数中的两个参数是由创建socket对象时指定的 ip地址类型 决定的,这里使用的是socket.AF_INET(IPv4),因此,bind()函数接收一个元组对象作为参数(HOST, PORT)

  • host可以是一个主机名,IP地址,或者空字符串。如果使用的是 IP地址,host必须是 IPv4格式的地址字符串。127.0.0.1是本地环路的标准写法,因此只有在主机上的进程才能够连接到server,如果设置为空字符串,它可以接受所有合法 IPv4地址的连接。

  • port应该是从1 - 65535的一个整数(0被保留了),它相当于是一个窗口和其他的客户端建立连接,如果想使用1 - 1024的端口,一些系统可能会要求要有管理员权限。

listen()使得server可以接受连接,它可以接受一个参数:backlog,用来指明系统可以接受的连接数量,虽然同一时刻只能与一端建立连接,但是其他的连接请求可以被放入等待队列中,当前面的连接断开,后面的请求会依次被处理,超过这个数量的连接请求再次发起后,会被server直接拒绝。

Python 3.5开始,这个参数是可选的,如果我们不明确指明,它就采用系统默认值。如果server端在同一时刻会收到大量的连接请求,通常要把这个值调大一些,在Linux中,可以在/proc/sys/net/core/somaxconn看到值的情况,详细请参阅:

accept()监听连接的建立,是一个阻塞式调用,当有client连接之后,它会返回一个代表这个连接的新的socket对象和代表client地址信息的元组。对于 IPv4 的地址连接,地址信息是 (host, port),对于 IPv6 ,(host, port, flowinfo, scopeid)

有一件事情需要特别注意,accept()之后,我们获得了一个新的socket对象,它和server以及client都不同,我们用它来进行和client的通信。

conn, addr = s.accept()
with conn:
    print('Connected by', addr)
    while True:
        data = conn.recv(1024)
        if not data:
            break
        conn.sendall(data)

conn是我们新获得的socket对象,conn.recv()也是一个阻塞式调用,它会等待底层的 I/O 响应,直到获得数据才继续向下执行。外面的while循环保证server端一直监听,通过conn.sendall将数据再发送回去。

Echo Client

import socket

HOST = '127.0.0.1'  # The server's hostname or IP address
PORT = 65431        # The port used by the server

with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    s.connect((HOST, PORT))
    s.sendall("Hello, world".encode("utf8"))
    data = s.recv(1024)

print('Received', data.decode("utf8"))

server相比,client更加简单,先是创建了一个socket对象,然后将它和server连接,通过s.sendall()将信息发送给server,通过s.recv()获得来自server的数据,然后将其打印输出。

在发送数据时,只支持发送字节型数据,所以我们要将需要发送的数据进行编码,在收到server端的回应后,将得到的数据进行解码,就能还原出我们能够识别的字符串了。

启动程序

我们要先启动server端,做好监听准备,然后再启动client端,进行连接。

这个信息是在client连接后打印出来的。

image

image

可以使用netstat这个命令查看socket的状态,更详细使用可以查阅帮助文档。

查看系统中处于监听状态的socket,过滤出了使用 TCP协议 和 IPv4 地址的对象:

image

如果先启动了client,会有下面这个经典的错误:

image

造成的原因可能是端口号写错了,或者server根本就没运行,也可能是在server端存在防火墙阻值了连接建立,下面是一些常见的错误异常:

Exception errno Constant Description
BlockingIOError EWOULDBLOCK Resource temporarily unavailable. For example, in non-blocking mode, when calling send() and the peer is busy and not reading, the send queue (network buffer) is full. Or there are issues with the network. Hopefully this is a temporary condition.
OSError ADDRINUSE Address already in use. Make sure there’s not another process running that’s using the same port number and your server is setting the socket option SO_REUSEADDR: socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1).
ConnectionResetError ECONNRESET Connection reset by peer. The remote process crashed or did not close its socket properly (unclean shutdown). Or there’s a firewall or other device in the network path that’s missing rules or misbehaving.
TimeoutError ETIMEDOUT Operation timed out. No response from peer.
ConnectionRefusedError ECONNREFUSED Connection refused. No application listening on specified port.

连接的建立

现在我们仔细看一下serverclient是怎样建通信的:

image

当使用环路网络((IPv4 address 127.0.0.1 or IPv6 address ::1))的时候,数据没有离开过主机跑到外部的网络。如图所示,环路网络是在主机内部建立的,数据就经过它来发送,从主机上运行的一个程序发送到另一个程序,从主机发到主机。这就是为什么我们喜欢说环路网络和 IP地址 127.0.0.1(IPv4) 或 ::1(IPv6) 都表示主机

如果server使用的时其他的合法IP地址,它就会通过以太网接口与外部网络建立联系:

image

如何处理多端连接

echo server最大的缺点就是它同一时间只能服务一个client,直到连接的断开,echo client同样也有不足,当client进行如下操作时,有可能s.recv()只返回了一个字节的数据,数据并不完整。

data = s.recv(1024)

这里所设定的参数 1024 表示单次接收的最大数据量,并不是说会返回 1024 字节的数据。在server中使用的send()与之类似,调用后它有一个返回值,标示已经发送出去的数据量,可能是小于我们实际要发送的数据量,比如说有 6666 字节的数据要发送,用上面的发送方式要发送很多此才行,也就是说一次调用send()数据并没有被完整发送,我们需要自己做这个检查来确保数据完整发送了。

因此,这里使用了sendall(),它会不断地帮我们发送数据直到数据全部发送或者出现错误。

所以,目前有两个问题:

  • 怎样同时处理多个连接?
  • 怎样调用send()recv()直到数据全部发送或接收。

要实现并发,传统方法是使用多线程,最近比较流行的方法是使用在Python3.4中引入的异步IO模块asyncio

这里准备用更加传统,但是更容易理解的方式来实现,基于系统底层的一个调用:select()Python中也提供了对应的模块:selectors ,它在原生的实现上进行了封装,通过使用DefaultSelector,能更加简单的完成任务。

select()通过了一种机制,它来监听操作发生情况,一旦某个操作准备就绪(一般是读就绪或者是写就绪),然后将需要进行这些操作的应用程序select出来,进行相应的读和写操作。到这里,你可能会发现这并没有实现并发,但是它的响应速度非常快,通过异步操作,足够模拟并发的效果了。

Muti-Connection Client and Server

Multi-Connection Server

import selectors

sel = selectors.DefaultSelector()
# ...
lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
lsock.bind((host, port))
lsock.listen()
print('listening on', (host, port))
lsock.setblocking(False)
sel.register(lsock, selectors.EVENT_READ, data=None)

echo server最大的不同就在于,通过lsock.setblocking(False),将这个socket对象设置成了非阻塞形式,与sel.select()一起使用,就可以在一个或多个socket对象上等待事件,然后在数据准备就绪时进行数据的读写操作。

sel.register()server注册了我们需要的事件,对server来说,我们需要 I/O 可读,从而进行client发送数据的读入,因此,通过selector.EVENT_READ来指明。

data用来存储和socket有关的任何数据,当sel.select()返回结果时,它也被返回,我们用它作为一个标志,来追踪拥有读入和写入操作的socket对象。

接下来是事件循环:

import selectors
sel = selectors.DefaultSelector()

# ...

while True:
    events = sel.select(timeout=None)
    for key, mask in events:
        if key.data is None:
            accept_wrapper(key.fileobj)
        else:
            service_connection(key, mask)

sel.select(timeout=None)是一个阻塞式调用,直到有socket对象准备好了 I/O 操作,或者等待时间超过设定的timeout。它将返回(key, events)这类元组构成的一个列表,每一个对应一个就绪的socket对象。

key是一个SeletorKey类型的实例,它有一个fileobj的属性,这个属性就是sokect对象。

mask是就绪操作的状态掩码。

如果key.data is None,我们就知道,这是一个server对象,于是要调用accept()方法,用来等待client的连接。不过我们要调用我们自己的accept_wrapper()函数,里面还会包含其他的逻辑。

如果key.data is not None,我们就知道,这是一个client对象,它带着数据来建立连接啦!然后我们要为它提供服务,于是就调用service_connection(key, mask),完成所有的服务逻辑。

def accept_wrapper(sock):
    conn, addr = sock.accept()  # Should be ready to read
    print('accepted connection from', addr)
    conn.setblocking(False)
    data = types.SimpleNamespace(addr=addr, inb=b'', outb=b'')
    events = selectors.EVENT_READ | selectors.EVENT_WRITE
    sel.register(conn, events, data=data)

这个函数用来处理与client的连接,使用conn.setblocking(False)将该对象设置为非阻塞状态,这正是我们在这个版本的程序中所需要的,否则,整个server会停止,直到它返回,这意味着其他socket对象进入等待状态。

然后,使用types.SimplleNamespace()构建了一个data对象,存储我们想保存的数据和socket对象。

因为数据的读写都是通过conn,所以使用selectors.EVENT_READ | selectors.EVENT_WRITE,然后用sel.register(conn, events, data=data)进行注册。

def service_connection(key, mask):
    sock = key.fileobj
    data = key.data
    if mask & selectors.EVENT_READ:
        recv_data = sock.recv(1024)  # Should be ready to read
        if recv_data:
            data.outb += recv_data
        else:
            print('closing connection to', data.addr)
            sel.unregister(sock)
            sock.close()
    if mask & selectors.EVENT_WRITE:
        if data.outb:
            print('echoing', repr(data.outb), 'to', data.addr)
            sent = sock.send(data.outb)  # Should be ready to write
            data.outb = data.outb[sent:]

这就时服务逻辑的核心,key中包含了socket对象和data对象,mask是已经就绪操作的掩码。根据sock可以读,将数据保存在data.outb中,这也将成为写出的数据。

if recv_data:
    data.outb += recv_data
else:
    print('closing connection to', data.addr)
    sel.unregister(sock)
    sock.close()

如果没有接收到数据,说明client数据发完了,sock的状态不再被追踪,然后关闭这次连接。

Multi-Connection Client

messages = [b'Message 1 from client.', b'Message 2 from client.']


def start_connections(host, port, num_conns):
    server_addr = (host, port)
    for i in range(0, num_conns):
        connid = i + 1
        print('starting connection', connid, 'to', server_addr)
        sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        sock.setblocking(False)
        sock.connect_ex(server_addr)
        events = selectors.EVENT_READ | selectors.EVENT_WRITE
        data = types.SimpleNamespace(connid=connid,
                                     msg_total=sum(len(m) for m in messages),
                                     recv_total=0,
                                     messages=list(messages),
                                     outb=b'')
        sel.register(sock, events, data=data)

使用connect_ex()而不是connect(),因为connect()会立即引发BlockingIOError异常。connect_ex()只返回错误码 errno.EINPROGRESS,而不是在连接正在进行时引发异常。连接完成后,socket对象就可以进行读写,并通过select()返回。

连接建立完成后,我们使用了types.SimpleNamespace构建出和socket对象一同保存的数据,里面的messages对我们要发送的数据做了一个拷贝,因为在后续的发送过程中,它会被修改。client需要发送什么,已经发送了什么以及已经接收了什么都要进行追踪,总共要发送的数据字节数也保存在了data对象中。

def service_connection(key, mask):
    sock = key.fileobj
    data = key.data
    if mask & selectors.EVENT_READ:
        recv_data = sock.recv(1024)  # Should be ready to read
        if recv_data:
            print('received', repr(recv_data), 'from connection', data.connid)
            data.recv_total += len(recv_data)
        if not recv_data or data.recv_total == data.msg_total:
            print('closing connection', data.connid)
            sel.unregister(sock)
            sock.close()
    if mask & selectors.EVENT_WRITE:
        if not data.outb and data.messages:
            data.outb = data.messages.pop(0)
        if data.outb:
            print('sending', repr(data.outb), 'to connection', data.connid)
            sent = sock.send(data.outb)  # Should be ready to write
            data.outb = data.outb[sent:]

client要追踪来自server的数据字节数,如果收到的数据字节数和发送的相等,或者有一次没有收到数据,说明数据接收完成,本次服务目的已经达成,就可以关闭这次连接了。

data.outb用来维护发送的数据,前面提到过,一次发送不一定能将数据全部送出,使用data.outb = data.outb[sent:]来更新数据的发送。发送完毕后,再messages中取出数据准备再次发送。

可以在这里看到最后的完整代码:

最后的运行效果如下:

image
image

还是要先启动server,进入监听状态,然后client启动,与server建立两条连接,要发送的信息有两条,这里分开发送,先将fist message分别发送到server,然后再发送second messageserver端收到信息后进行暂时保存,当两条信息都收到了才开始进行echoclient端收到完整信息后表示服务结束,断开连接。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 200,045评论 5 468
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 84,114评论 2 377
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 147,120评论 0 332
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,902评论 1 272
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,828评论 5 360
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,132评论 1 277
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,590评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,258评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,408评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,335评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,385评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,068评论 3 315
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,660评论 3 303
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,747评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,967评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,406评论 2 346
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,970评论 2 341

推荐阅读更多精彩内容