Linux C/C++网络编程实战-陈硕-笔记20-使用非阻塞IO

基于IO复用(非阻塞IO)实现的 netcat

使用非阻塞IO可以有效避免上述情况的发生。但非阻塞IO在编程上要比阻塞IO更难,并且在程序的维护上比较痛苦。一般使用非阻塞IO编程时建议使用一些封装好的网络库比较容易编写。
Linux C/C++网络编程实战-陈硕-笔记20-使用非阻塞IO_第1张图片

代码

  • recipes/python/netcat-nonblock.py
  • netcat-nonblock
    #!/usr/bin/python
    
    import errno
    import fcntl
    import os
    import select
    import socket
    import sys
    # 设置非阻塞
    def setNonBlocking(fd):
        flags = fcntl.fcntl(fd, fcntl.F_GETFL)
        fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
    
    # 非阻塞的写数据
    def nonBlockingWrite(fd, data):
        try:
            nw = os.write(fd, data)
            return nw
        except OSError as e:
            if e.errno == errno.EWOULDBLOCK:
                return -1
    
    
    def relay(sock):
        socketEvents = select.POLLIN
        poll = select.poll()
        poll.register(sock, socketEvents)
        poll.register(sys.stdin, select.POLLIN)
    
        setNonBlocking(sock)
        # setNonBlocking(sys.stdin)
        # setNonBlocking(sys.stdout)
    
        done = False
        socketOutputBuffer = ''
        while not done:
            events = poll.poll(10000)  # 10 seconds
            for fileno, event in events:
                if event & select.POLLIN:
                    if fileno == sock.fileno():		# 套接字可读(该文件描述符号 等于 网络链接套接字文件描述符)
                        data = sock.recv(8192)
                        if data:
                            nw = sys.stdout.write(data)  # stdout does support non-blocking write, though
                        else:
                            done = True
                    else:
                        assert fileno == sys.stdin.fileno()	# 标准输入可读
                        data = os.read(fileno, 8192)		# 将输入读到date中
                        if data:
                            assert len(socketOutputBuffer) == 0	# 读之前确认buf中没有数据,否则可能会造成数据乱序
                            nw = nonBlockingWrite(sock.fileno(), data)	# 写数据
    	                        if nw < len(data):	# 如果数据没有写完,需要保存剩余的数据到 scoketOutputBuffer中
                                	if nw < 0:
                                    	nw = 0
    	                            socketOutputBuffer = data[nw:]	# 暂存没有写完的数据
    	                            socketEvents |= select.POLLOUT	# 开始关注 pollout 事件(此时socketEvents 同时有sock的读写事件)
    	                            poll.register(sock, socketEvents)	# 重新注册sock,关注sock的读(接收对端数据)和写(本端有未发送完的数据)
    	                            poll.unregister(sys.stdin)	# 不再关注stdin事件
                        else:	# 如果没有标准输入,则表示可以关闭连接了
                            sock.shutdown(socket.SHUT_WR)
                            poll.unregister(sys.stdin)
                if event & select.POLLOUT:
                    if fileno == sock.fileno():	# 判断是否是网络套接字上的事件
                        assert len(socketOutputBuffer) > 0	# 断言,只有当socketOutputBuffer有剩余的时候,才需要向sock写数据
                        nw = nonBlockingWrite(sock.fileno(), socketOutputBuffer)# 向sock文件描述符写数据
                        if nw < len(socketOutputBuffer):	# 如果仍然没写完,则保留余下的数据,继续重复上述操作
                            assert nw > 0
                            socketOutputBuffer = socketOutputBuffer[nw:]
                        else:								# 如果数据写完了,则套接字上的写事件不需要了
                            socketOutputBuffer = ''
                            socketEvents &= ~select.POLLOUT	# 取消套接字写事件标记,此时的socketEvents == select.POLLIN
                            poll.register(sock, socketEvents)# 重新注册套接字事件(关注套接字上的读事件)
                            poll.register(sys.stdin, select.POLLIN) # 关注,标准输入上的读事件
    
    
    
    def main(argv):
        if len(argv) < 3:
            binary = argv[0]
            print "Usage:\n  %s -l port\n  %s host port" % (argv[0], argv[0])
            print (sys.stdout.write)
            return
        port = int(argv[2])
        if argv[1] == "-l":
            # server
            server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
            server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            server_socket.bind(('', port))
            server_socket.listen(5)
            (client_socket, client_address) = server_socket.accept()
            server_socket.close()	# 关闭监听套接字
            relay(client_socket)
        else:
            # client
            sock = socket.create_connection((argv[1], port))
            relay(sock)
    
    
    if __name__ == "__main__":
        main(sys.argv)
    
  • 因为设置为非阻塞IO后,写操作会有无法将全部数据写完的情况发生(short write),因此在发生数据未写完时,我们再注册一个写事件,让程序将未写完的数据全部写完

程序结构

Linux C/C++网络编程实战-陈硕-笔记20-使用非阻塞IO_第2张图片

测试

  • 测试结果:可以看到这次,即使 nc 上有输入也不会阻塞程序
    Linux C/C++网络编程实战-陈硕-笔记20-使用非阻塞IO_第3张图片
    Linux C/C++网络编程实战-陈硕-笔记20-使用非阻塞IO_第4张图片

非阻塞IO 中的 short write

Linux C/C++网络编程实战-陈硕-笔记20-使用非阻塞IO_第5张图片
如何处理非阻塞IO中的 short write

一般来说在非阻塞编程中:

  • 对于非阻塞的读,如果读数据不全,我们需要将数据缓存,等凑够一条完整消息再触发消息处理逻辑。
  • 对于非阻塞的写,通常是网络库需要实现的功能,我们需要做的是告诉网络库我们需要发送多少数据,至于网络库内部如何处理事件我们在编程阶段不关心。

以发数据为例:

  • 如果数据发送不完整,剩下的数据需要放置到一个发送的缓冲区中。
    • 如果缓冲区非空,则我们不能对新数据write。
      因为这会造成数据乱序。只有等上一条消息全部发送成功后才可以对新消息进行发送。
      方案一:先尝试发送一次数据,如果数据发送不完整,将剩余数据存放在发送缓冲区(应用层的),然后注册POLLOUT事件用于处理剩余的数据发送。
  • 或者始终从缓冲区发送数据。
    方案二:将所有数据都存放在发送缓冲区,然后再去注册POLLOUT事件,只通过POLLOUT事件处理数据的发送。
  • 向套接字中写数据,关注POLLOUT事件。
    • 当POLLOUT准备好时,开始从发送缓冲区(应用层)中取数据向sock中写
    • 当发送换缓冲区(应用层)为空时,停止关注POLLOUT事件。
      注意,如果忘记取消关注POLLOUT事件,则认为sock一直可写(LT模式持续通知我们有事件),而实际上我们并没有数据向sock写,会进入一种busy loop状态,大量空耗cup过度占用资源。

对方接收数据缓慢

Linux C/C++网络编程实战-陈硕-笔记20-使用非阻塞IO_第6张图片
设想,本端在发送一个文件时,将文件加载到内存中然后通过网络向对端发送,而如果对方接受缓慢,那本端的发送方就要迁就对方从而缓慢发送,但是本端内存中缓存的数据就会持续占用在内存中。

如果待发送的数据很多的话,那么一味的将文件读取到内存中。等待向对端发送显然是不明智的。因为这将会占用大量的内存资源。

这种场景类似于水池灌溉模型,即一个水池一边放水一边注水。放水的下流出口用于灌溉农田,注水的上流入口取自水库抽取的水源。假设现在要达到灌溉最大化,既不浪费水源,又能以最大速率灌溉农田。那么由于 V 注水 !=V 放水,必然会产生下面这两种结果:

  • 如果注水量大于放水量,那么一段时间后池子将会溢出(浪费水源)。
  • 如果注水量小于放水量,那么一段时间后池子内将不会存留下水(灌溉效率没有达到最大化)。
    .
    最好的状态是池子内永远有一定量的水位,这样从池子内流出的水将以最大速率流出灌溉农田,同时也不会浪费水源。
    .
    那么解决方案就是认为设定一个最高/低水位(hight/low water mark),如果水池内的水位高于最高水位则停止注水,如果水位低于最低水位则开始注水。这样使得水位在 hwm ~ lwm 之间浮动,而水池出水率始终是最大值。

同理,我们可以参考高低水位的方式去设计。例如在向对端发送数据时,内存中缓冲的数据已经高出规定阈值时,我们可以考虑不去读对方的下一个请求直到本次发送完成,并且可以限制从本地读取待发送文件的速率。

但这终究不是一个完美的解决方案,对于接收端大量频繁的请求而言,我们不 read() 这些请求并不是一个好的解决方案,最好的方式是接收两方进行协议层面的商定,通过滑动窗口的思想告知接收端是否可以开启下一次的请求。这样方可避免由于接收方大量的数据请求而造成发送端发缓冲区数据的大量堆积(比如,接收端每次get image请求,发送端将对应image数据发送给接收端。对于接收端而言发送一次请求耗费的数据量很少,而发送发要回应的每个请求的数据量等很大,如果发送方一次接收到多个请求则这些应答数据将会占满发送发的缓冲区)。

LT 和 ET 模式

Linux C/C++网络编程实战-陈硕-笔记20-使用非阻塞IO_第7张图片

  • select() 与 poll() 都是 LT 模式。 如果有事件到达,还没有进行处理,则它会一直通知,直到事件被处理。
  • epoll() 即 edge-poll。 它同时支持 LT 模式与 ET 模式。
  • 能否结合两种模式的特点,分别在不同的场景使用不同的模式
    • 对于ET模式而言, 更适用于write事件和accept事件(accept如果文件描述符用完,会陷入死循环中,因此使用模式更好)
    • LT模式 更适用于read()事件,它不会造成接收的饥饿,ET模式可能会造成数据接收不完整的情况。
    • 可惜的是,目前内核中使用同一种数据结构表示读和写事件,读写事件放在一个就绪列表中,在读出后才判断是读事件还是写事件。因此,我们无法实现在不同的场景使用不同的模式。 值得注意的是,许多第三方网络库都使用的是LT模式,一般来说为了互相的兼容推荐使用LT模式。

你可能感兴趣的:(Linux,linux,网络,c语言)