Python网络编程 7.1 单线程、多线程与多进程服务器

本文详细介绍了Python网络编程中的服务器类型,包括单线程、多线程/多进程和异步服务器的工作原理和优缺点。单线程服务器易受DDoS攻击,多线程/多进程服务器受限于并发机制规模,而异步服务器则能更有效地利用资源。通过示例代码解释了如何实现这些服务器,并探讨了服务器部署的两种观点。最后,分析了异步服务器的实现机制,强调了状态机和缓冲区在异步服务器中的关键作用。
摘要由CSDN通过智能技术生成

网络服务器可以分为三大类:

  • 单线程服务器。同一时刻只能为一个客户端提供服务,此时所有其他客户端都要进行等待。这种情况下CPU很可能处在近乎空闲的状态。
  • 多线程、多进程服务器。使用多个线程/进程,每个线程或进程内都运行着一个单线程服务器。
  • 使用异步网络的服务器。在自己的代码中使用异步网络操作来支持多路复用,而不是直接使用操作系统提供的多路复用。
我们可能会把网络部署到单台机器上,也可能部署到多台机器上。在前面DNS的学习中我们已经分析过,要访问某服务时,DNS服务器会返回运行该服务的所有IP地址(如果有多个的话),如果客户无法连接第一个,那么就再连接第二个,以此类推。业界如今已经广泛运用了该方法:在服务前段配置一个负载均衡器(load balancer),客户端直接连接到负载均衡器,然后由负载均衡器将连接请求转发到实际的服务器,如果某台服务器宕机了,那么负载均衡器将转发至该服务器的连接请求予以停止,直到该服务器恢复服务为止。这样,服务器的故障对于大量用户来说是不可见的。    大型的互联网服务则结合了上面两种方法:每个机房中都配置一个负载均衡器与服务器群,而公共的DNS名则会返回与用户距离最近的机房中的负载均衡器的IP地址。

无论服务器的架构是简单还是复杂,都需要使用某种方式在物理或虚拟机器上运行的我们的服务器代码,这个过程叫做服务器的部署(deployment),人们对部署的看法可以分为两大类。

  • 较为旧式的技术观点是,为每个服务器都编写服务所提供的所有功能:通过两次fork()创建一个Unix守护进程(或是将自己注册为一个Windows服务),安排进行系统级的日志操作,支持配置文件以及提供启动、关闭和重启的相关机制。
  • 另一种方法随着“十二要素应用”的提出而流行,该方法提倡只实现服务器程序必需功能的最小集合。它将每个服务实现为普通的前台程序,而不是将其实现为守护进程。这样的程序从环境变量(Python中的sys.environ字典)而不是系统级的配置文件中获取所需的配置选项。它通过环境变量中指定的选项连接到任一的后端服务,并且直接将日志信息输出到屏幕,甚至直接使用Python自己提供的print()函数。另外该方法通过打开并监听环境配置指定的任意端口来接收网络请求。
无论是哪一种部署方式,如何最有效地使用操作系统网络栈和操作系统进程对网络请求进行响应的问题都是一样的,即要令系统尽可能的繁忙,这样就能把客户端获取网络请求响应前的等待时间减少到最短。

协议--链接 定义了一个简单的客户端、服务器通信协议。在这个协议中,客户端可以询问三个问题,这三个问题都以纯文本的ASCII字符表示。在发出请求的问题后,客户端将等待服务器的应答。和HTTP协议一样,只要套接字保持打开,客户端发起问题请求的次数是没有限制的。客户端不再发起问题请求后,无需发出任何警告即可将连接关闭。每个问题的结尾用福ASCII的问号字符表示问题的结束。该协议集合了之前所学到的大部分知识,应当把每一行代码都理解透,争取自己可以写出这样的协议。

其中,客户端希望服务器理解的三个问题作为字典的key列出,对应的回答则以value的形式存储。get_answer()函数是为了在字典中安全地查找回答而编写的一个简单的快速函数。如果传入的问题无法识别的话,该函数会返回一个简短的错误信息。注意到客户端的请求始终以问号结尾,而服务器的回答始终以句点结尾(即使是返回错误信息),这两个标点符号为这个迷你协议提供了封帧的功能。

  • accept_connections_forever()函数中只包含一个简单的循环,不断通过监听套接字接收连接请求,并且使用print()把每个连接的客户端打印出来,然后将连接套接字作为参数传递给handle_conversation()。
  • handle_conversation()包含一个无限循环,不断地处理请求。该程序会捕捉所有可能发生的错误。这样的设计使得客户端套接字的任何问题都不会引起程序的崩溃。如果客户端完成了所有的请求并且已经挂起,那么最内层的数据接收循环会抛出EOFError异常作为信号传递的方式。这一现象在本例的协议中是很常见的(在HTTP协议中也一样),它并不是一个真正的异常事件。因此程序专门在一个单独的except从句中捕捉了EOFError异常,而将其他所有的异常都视为错误,这些错误捕捉后都会通过print()进行输出。finally从句能够确保无论该函数通过哪一条代码路径退出,始终都会将客户端套接字关闭。Python允许对已经关闭的文件以及套接字对象重复调用close()函数,且次数不限,因此通过这种方法运行close()函数始终是安全的。
  • recv_until()函数使用前面学过的封帧方法进行封帧,只要不断累加的字节字符串没有形成一个完整的问题,就会不断重复调用套接字的recv()方法。

上面的程序就是用来构建各种服务器的工具箱。




1.单线程服务器

结合上面的工具,只需要三行代码就可以完成这个简单的单线程服务器。

import zen_utils

if __name__ == '__main__':
   address = zen_utils.parse_command_line('Simple single-threaded server')
   listener = zen_utils.create_srv_socket(address)
   zen_utils.accept_connections_forever(listener)


上面的这个服务器要求提供一个命令行参数--服务器用来监听连接请求的接口。如果要防止LAN或网络中的其他用户访问该服务器,应指定标准本地主机IP地址127.0.0.1作为监听接口。

更大胆些,提供空字符串作为参数,这在Python中表示当前机器的任意接口,这样就能通过本机的所有接口提供服务。通过命令行的 -p选项也可以指定其他的端口进行监听。再运行一个客户端脚本:

import argparse, random, socket, zen_utils

def client(address, cause_error=False):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect(address)
    aphorisms = list(zen_utils.aphorisms)
    if cause_error:
        sock.sendall(aphorisms[0][:-1])
        return
    for aphorism in random.sample(aphorisms, 3):
        sock.sendall(aphorism)
        print(aphorism, zen_utils.recv_until(sock, b'.'))
    sock.close()

if __name__ == '__main__':
    parser = argparse.ArgumentParser(description='Example client')
    parser.add_argument('host', help='IP or hostname')
    parser.add_argument('-e', action='store_true', help='cause an error')
    parser.add_argument('-p', metavar='port', type=int, default=1060,
                        help='TCP port (default 1060)')
    args = parser.parse_args()
    address = (args.host, args.p)
    client(address, args.e)


该客户端脚本分别运行两次,cause_error分别赋为False和True,服务端效果:上面3行是无错误的效果,下面2行是客户端错误时的效果。

客户端cause_error为False时的效果:True时,客户端无输出。

对这个单线程服务器进行攻击非常容易,只需要连接该服务器,并且永远不关闭该连接即可。聪明的单线程服务器可能会通过sock.settimeout()设置超时参数。此时只要调整下DDoS攻击工具,使其发送请求的间隔不超过服务器超时参数设置的最长等待时间即可,这样就没有其他任何客户端能够使用该服务器了。

另外,单线程服务器无法再等待客户端发送下一个请求时进行其他操作,因此无法有效利用服务器的CPU和系统资源。



2.多线程、多进程服务器 是通过操作系统的内置支持,使用多个控制线程(所有线程可以共享相同内存空间也可以完全独立运行)单独运行同一段代码。 该方法的优点是简洁:直接使用单线程服务器的代码,创建多个线程运行它的多个副本。                      缺点是:服务器能够同时通信的客户端数量受操作系统并发机制规模的限制。即使某个客户端处于空闲状态,或是运行缓慢状态,它也会占用整个线程或进程。就算程序被recv()阻塞,也会占用系统RAM以及进程表中的一个进程槽。当同时运行的线程数量达到几千甚至更多时,操作系统很少能够维持良好的表现。此时系统在切换服务的客户端时需要进行大量上下文转换,这使得服务器的效率大大降低。

问题:我们可能会觉得多线程或多进程服务器需要使用一个主控制线程来不断运行accept()循环,然后将新创建的客户端套接字交给队列中的工作线程来处理。幸运的是,操作系统大大简化了这一操作。每个线程都可以拥有服务器监听套接字的一个副本,并运行自己的accept()函数。操作系统会将每个新的客户端连接交由任何运行了accept()函数并处于等待的线程来处理。如果所有线程都处在繁忙状态的话,操作系统会将该连接置于队列中,直到某个线程空闲为止。一个多线程服务器例子:

import zen_utils
from threading import Thread

def start_threads(listener,workers = 4):
    t = (listener,)                #一个可迭代的元组,逗号不能去掉
    for i in range(workers):
        Thread(target=zen_utils.accept_connections_forever,args=t).start()       #accept_connections_forever后面没有()。

if __name__ == '__main__':
    address = ('',1061)
    listener = zen_utils.create_srv_socket(address)
    start_threads(listener)
这只是多线程程序的一种可能涉及。主线程启动n个服务器线程,然后退出。主线程认为这n个工作线程将永远运行,因此运行这些线程的进程也会保持运行状态。除此之外还有其他可选的设计。例如,主线程可以保持运行,并且成为一个服务器线程。主线程也可以作为一个监控线程,每隔一段时间就检查一下n个服务器线程是否仍然在运行。如果有服务器线程停止运行了,主线程就将其重启。如果不适用threading.Thread,而适用multiprocessing.Process,那么操作系统会为每个线程分配独立的内存空间以及文件描述符,这会增加操作系统的开销,但是能够更好地隔离进程,进一步降低服务器线程造成主监控进程崩溃的概率。    

由于内部的服务对于客户端来说是不可见的,因此攻击者无法简单地打开很多空闲的连接,以使我们的线程池或进程池耗尽资源。

socketserver模块      将上述多线程模式分为了两个模式:第一个是用于打开监听套接字并接受客户端连接请求的server模式,第二个是用于通过某个打开的套接字与特定客户端进行会话的handler模式。结合使用这两个模式时,我们需要实例化一个server对象,然后将一个handler对象作为参数传递给server对象。    由于启动线程的数量是由服务器的客户端连接池来决定的---即不限制服务器最终启动的线程数量,使得攻击者可以很容易令服务器过载。因此在开发用于生产环境以及面向客户的服务时,并不推荐使用这个模块。



3.异步服务器                从服务器向客户端发送响应到接收客户端的下一个请求之间有一段时间的间隔,如何在不为每个客户端分配一个操作系统级的控制线程的前提下保证CPU在这段时间内处于繁忙状态呢?答案就是可以采用一种异步(asynchronous)模式来编写服务器。使用这种方法,代码就不需要等待数据发送至某个特定的客户端或由这个客户端接收。相反,代码可以从在整个处于等待的客户端套接字列表中读取数据。只要任何一个客户端做好了进行通信的准备,服务器就可以向该客户端发送响应。

现代操作系统网络栈的两个特点使得对该模式的应用成为了现实。1.网络栈提供了一个系统调用,支持进程为等待客户端套接字列表中的套接字而阻塞,而不是只等待一个单独的客户端套接字。这样一来,就可以使用一个线程来同时为成千上万的客户端套接字提供服务。  2.可以将一个套接字配置为非阻塞套接字。非阻塞套接字在进行send()或recv()系统调用都会立刻返回。如果发生延迟的话,那么调用方会负责在稍后客户端准备好继续进行交互时重试。

异步这一术语就表示服务器代码从来不会停下来等待某个特定的客户端,即运行代码的控制线程不是同步的。换句话说,控制线程不会以锁步的方式等待任何一个进行会话的客户端。相反,异步服务器可以在所有连接的客户端之间自由切换,并提供相应的服务。我们以poll()为例帮助理解一个完整的异步框架背后的原理,并将其运用到自己程序的异步实现中去。


代码运行不了,不知道什么原因。跳过代码部分,有兴趣的看书Page123或者GitHub上作者的源码。


分析异步服务器:其精髓在于,使用了自己的数据结构来维护每个客户端会话的状态,而没有依赖操作系统在客户端活动改变时进行上下文切换。这个服务器有两层循环,,首先是一个不断调用poll()的while循环。一次poll()调用可能返回多个事件,因此这个while循环中还要有一个循环,用于处理poll()返回的每一个事件。我们将这两层迭代隐藏在一个生成器里,这样就避免了主服务器循环因为这两次循环迭代而多用两个不必要的缩进。    

这个异步服务器真正核心在于它的缓冲区:在等待某个请求完成时,会将收到的数据存储byte_received字典中;在等待操作系统安排发送数据时,会将要发送的字节存储在bytes_to_send中。这两个缓冲区与我们告知poll()要在每个套接字上等待的事件一起形成了一个完整的状态机,用于一步一步地处理客户端会话。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值