Socket阻塞与非阻塞,同步与异步、I/O多路复用

1.概念

  在进行网络编程时,我们常常见到同步(Sync)/异步(Async),阻塞(Block)/非阻塞(Unblock)四种调用方式:

  同步/异步(关注的是消息通信机制

    同步:所谓同步,就是说调用者发送一个请求,在没有的得到被调用者时处理结果时会一直等待下去,就是,必须一件事一件事去做。

      例如普通B/S模式(同步):提交请求->等待服务器处理(客户端浏览器不能干任何事)->处理完毕返回

   异步:所谓异步,就是说调用者发送一个请求,被调用者不能立刻返回结果。实际等到这个被调用者完成后通过状态、通知和回调来通知调用者。

      例如 ajax请求(异步): 请求通过事件触发->服务器处理(浏览器仍然可以作其他事情)->处理完毕

  举个通俗的例子:

    你打电话问书店老板有没有《算法导论》这本书,如果是同步通信机制,书店老板会说,你稍等,”我查一下",然后开始查啊查,等查好了(可能是5秒,也可能是一天)告诉你结果(返回结果)。  如果是异步通信机制,老板会说,好的,我查一下,之后打电话通知你,就立即挂掉电话,然后查好了,他会主动打电话给你。在这里老板通过“回电”这种方式来回调。

  阻塞/非阻塞(关注的是程序在等待调用结果(消息,返回值)时的状态

    阻塞:阻塞调用是指调用结果返回之前,当前线程会被挂起(线程进入非可执行状态,在这个状态下,cpu不会给线程分配时间片,即线程暂停运行)。调用线程只有在得到结果之后才会返回。

    非阻塞:非阻塞调用是指调用结果会立刻返回,当前线程不会被挂起。

   还是上面的例子,
   你打电话问书店老板有没有《算法导论》这本书,你如果是阻塞式调用,你会一直把自己“挂起”,直到得到这本书有没有的结       果,如果是非阻塞式调用,你不管老板有没有告诉你,你自己先一边去玩了, 当然你也要偶尔过几分钟check一下老板有没有     返回结果。

  从这两个例子就可以看出,同步/异步和阻塞/非阻塞关注问题的层面不同。

  在处理 IO 的时候,阻塞和非阻塞都是同步 IO。
  只有使用了特殊的 API 才是异步 IO。

从用户代码的角度,I/O操作的系统调用分为“阻塞”和“非阻塞”两种。

  “阻塞”的调用会在I/O调用完成前,挂起调用线程,即CPU会不再执行后续代码,而是等到I/O完成后再回来继续执行,在用户代码看来,线程停止执行了,在调用处等待了。

  “非阻塞”“非阻塞”的调用则不同,I/O调用基本上是立即返回,而且往往实际上I/O此时并没有完成,所以需要用户的程序轮询结果。

但是由于服务器要同时服务多个客户端,所以需要同时操作多个Socket。

可以看到,如果使用阻塞的IO方式,因为每个Socket都会阻塞,为了同时服务多个客户端,需要多个线程同时挂起;而如果采用非阻塞的调用方式,则需要在一个线程中不断轮训每个客户端是否有数据到来。

显然纯粹阻塞式的调用不可取,非阻塞式的调用看起来不错,但是仍不够好,因为轮询实际也是通过某种系统调用完成的,相当于在用户空间进行的,效率不高,如果能够在内核空间进行这种类似轮询,然后让内核通知用户空间哪个IO就绪了,就更好了。于是引出接下来的概念:IO多路复用

   I/O多路复用

    IO多路复用是一种系统调用,内核能够同时对多个IO描述符进行就绪检查。当所有被监听的IO都没有就绪时,调用将阻塞;当至少有一个IO描述符就绪时,调用将返回,用户代码可通过检查究竟是哪个IO就绪来进一步处理业务。这里必须明确IO复用和IO阻塞与否并不是一个概念,IO复用只检测IO是否就绪(读就绪或者写就绪等),具体的数据的输入输出还是需要依靠具体的IO操作完成(阻塞操作或非阻塞操作)。最典型的IO多路复用技术有selectpollepoll等。select具有最大数量描述符限制,而epoll则没有,并且在机制上,epoll也更为高效。select的优势仅仅是跨平台支持性,所有平台和较低版本的内核都支持select模式,epoll则不是。

1、select

它通过一个select()系统调用来监视多个文件描述符的数组

 select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,不过可以通过修改宏定义甚至重新编译内核的方式提升这一限制。

  另外,select()维护一个存储大量文件描述符的数据结构,在用户态和内核的地址空间之间复制的开销也线性增长。调用select()会对所有socket进行一次线性扫描,所以这也浪费了一定的开销。

2、poll

它和select在本质上没有多大差别,但是poll没有最大文件描述符数量的限制。

 另外,select()和poll()将就绪的文件描述符告诉进程后,如果进程没有对其进行IO操作,那么下次调用select()和poll()的时候将再次报告这些文件描述符,所以它们一般不会丢失就绪的消息,这种方式称为水平触发(Level Triggered)。

3、epoll

  支持边缘触发

   Edge Triggered,只告诉进程哪些文件描述符刚刚变为就绪状态,它只说一遍,如果我们没有采取行动,那么它将不会再次告知,性能较高,但是代码实现也就相应复杂的多。

  内存映射技术

   epoll当我们调用epoll_wait()获得就绪文件描述符时,返回的不是实际的描述符,而是一个代表就绪描述符数量的值,你只需要去epoll指定的一个数组中依次取得相应数量的文件描述符即可,这里也使用了内存映射(mmap)技术,这样便彻底省掉了这些文件描述符在系统调用时复制的开销。

  基于事件的就绪通知方式

    也是边缘触发的本质。在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知。

在IO相关的编程中,IO复用起到的作用相当于一个阀门,让后续IO操作更为精准高效。

2.编程模型

综上讨论,我们在进行实际的Socket编程的时候,无论是客户端还是服务端,大致有几种模式可以选择:

  1. 阻塞式。纯采用阻塞式,这种方式很少见,基本只会出现在demo中。多个描述符需要用多个进程或者线程来一一对应处理。
  2. 非阻塞式。纯非阻塞式,对IO的就绪与否需要在用户空间通过轮询来实现。
  3. IO多路复用+阻塞式。仅使用一个线程就可以实现对多个描述符的状态管理,但由于IO输入输出调用本身是阻塞的,可能出现某个IO输入输出过慢,影响其他描述符的效率,从而体现出整体性能不高。此种方式编程难度比较低。
  4. IO多路复用+非阻塞式。在多路复用的基础上,IO采用非阻塞式,可以大大降低单个描述符的IO速度对其他IO的影响,不过此种方式编程难度较高,主要表现在需要考虑一些慢速读写时的边界情况,比如读黏包、写缓冲不够等。

下面以select为例,整理 在select下,socket的阻塞和非阻塞的一些问题。这些细节在编写基于Socket的网络程序时,尤其是底层数据收发时,是十分重要的。

socket读就绪:

  • 【阻/非阻】接收缓冲区有数据,数据量大于SO_RCVLOWAT水位(默认是0)。此时调用recv将返回>0(即读到的字节数)。
  • 【阻/非阻】对端关闭,即收到FIN。此时调用recv将返回=0。
  • 【阻/非阻】accept到一个新的连接,此时accept通常不会阻塞。
  • 【阻/非阻】socket发生某种错误。此时调用recv将返回-1,并应通过getsockopt得到相应的待处理错误。

socket写就绪:

  • 【阻/非阻】发送缓冲区有空余的空间,空间大小大于SO_SNDLOWAT水位(默认是2048)。这种就绪是水平触发的,只要有空间就会触发写就绪,即如果保持对这种套接字的就绪检查将使得select每次都认为有描述符写就绪。所以应当对描述符进行写状态管理,一旦某个描述符可写,应立即停止对该描述符的写状态检查,直到写缓冲区满后,再次select写状态。
  • 【阻/非阻】连接的写半部关闭,此时调用send将产生SIGPIPE信号。
  • 【非阻】connect完成。由于非阻的connect将不会阻塞握手过程,所以,当握手在后续时刻完成后,在此保持写状态检查,将触发一次就绪,表示connect完成。
  • 【阻/非阻】socket发生某种错误。此时调用send将返回-1,并应通过getsockopt得到相应的待处理错误。

3.Demo

接下来,使用python select模块来实现IO多路复用+非阻塞式的例子

服务器端
#!/usr/bin/python
# -*- coding: UTF-8 -*-
"socket通信服务器端,支持多个客户端同时连接"
import pandas as pd
import select
import socket
from queue import Queue
import queue

__author__ = 'lnp'

# 创建一个tcp服务
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# 将服务设置为非阻塞
server.setblocking(False)
# 绑定通讯端口
server_address = ('localhost', 9999)
print("starting up on %s port %s" % server_address)
server.bind(server_address)
# 设置最大连接数
server.listen(5)
# 可能有读操作的套接字
inputs = [server]
# 可能有写操作的套接字
outputs = []
# 消息队列字典
message_queues = {}

while inputs:

    print("waiting for the next event")
    # 开始select监听,一旦调用socket的send,recv函数,将再次调用此模块
    readable, writable, exceptional = select.select(inputs, outputs, inputs)

    # 处理readable
    for s in readable:
        # 如果服务器端发生变化,
        # 新客户端连接进来
        if s is server:
            # 接收连接
            client_sock, client_addr = s.accept()
            print("connection from ", client_addr)
            # 设置客户端为非阻塞
            client_sock.setblocking(0)
            # 将客户端也加入监听列表,客户端收到消息时,select将会触发
            inputs.append(client_sock)
            # 为客户端创建一个消息队列,用来保存客户端发送的消息
            message_queues[client_sock] = Queue()

        # 客户端发生变化
        # 老用户发送数据,进行接收
        else:
            data = s.recv(1024).decode('utf-8')
            # 客户端未断开
            if data:
                print('received "%s" from %s' % (data, s.getpeername()))
                if s not in outputs:
                    outputs.append(s)
                message_queues[s].put(data)

            # 客户端断开连接
            else:
                # 将客户端从outputs、inputs列表移除
                if s in outputs:
                    outputs.remove(s)
                inputs.remove(s)
                print("%s has closed" % s.getpeername())
                s.close()
                # 删除客户端对应的消息队列
                del message_queues[s]

    # 处理writable
    for s in writable:
        try:
            # 客户端传过来的数据
            total_data = ''
            while not message_queues[s].empty():
                total_data += message_queues[s].get_nowait()
            #将客户端发送的的全部数据发送给客户端
            s.sendall(total_data.encode("utf-8"))
            print('sending "%s" to %s' % (jsonStr, s.getpeername()))
        except queue.Empty:
            print('output queue for', s.getpeername(), 'is empty')
            outputs.remove(s)

    # 处理异常的情况exceptional
    for s in exceptional:
        print('exception condition on', s.getpeername())
        # 停止对异常套接字的监听
        inputs.remove(s)
        if s in outputs:
            outputs.remove(s)
        s.close()
        # 删除队列
        del message_queues[s]

  客户端

#!/usr/bin/python
# -*- coding: UTF-8 -*-
""
import socket

__author__ = 'lnp'


server_address = ('localhost', 9999)

# Create aTCP/IP socket

socks = [socket.socket(socket.AF_INET, socket.SOCK_STREAM)]

# Connect thesocket to the port where the server is listening

print('connecting to %s port %s' % server_address)

# 连接到服务器并发送信息
for s in socks:
    inp = input('>>>')
    s.connect(server_address)
    result = s.sendall(inp.encode('utf-8'))
    if not result: #发送成功
        print("发送成功")
    else:
        print("未发送成功")

#接收消息
for s in socks:
    result = ''
    while True:
        data = s.recv(1024).decode('utf-8')
        if  data:
            result += data
        else:
            break
    print('%s: received "%s"' % (s.getsockname(), result))
    print('closingsocket', s.getsockname())
    s.close()

补充:

非阻的调用recvsendaccept,分别地,如果收缓冲中无数据、发送缓冲不够空间发、没有外来连接,将立即返回,此时全局errno将得到EWOULDBLOCKEAGIAN,表示“本应阻塞的调用,由于采用了非阻塞模式,而返回”。非阻的调用connect将立即返回,此时全局errno将得到EINPROGRESS,表示连接正在进行。

猜你喜欢

转载自blog.csdn.net/ln1593570p/article/details/107856931