Python全栈开发【进程、线程、IO多路复用】

Python全栈【进程、线程、IO多路复用】

本节内容:

  • 进程
  • 线程
  • 线程锁
  • 协程
  • 事件驱动
  • I/O多路复用
  • selectors
  • socketserver
进程

1、进程就是一个程序在一个数据集上的一次动态执行过程,进程是资源分配的最小单元。

2、进程一般由程序、数据集、进程控制块三部分组成。

编写的程序用来描述进程要完成哪些功能以及如何完成;

数据集则是程序在执行过程中所需要使用的资源;

进程控制块用来记录进程的外部特征,描述进程的执行变化过程,系统可以利用它来控制和管理进程,它是系统感知进程存在的唯一标志。
View Code

3、线程的上一级就是进程,进程可包含很多线程,进程和线程的区别是进程间的数据不共享,多进程也可以用来处理多任务,不过多进程很消耗资源,

计算型的任务最好交给多进程来处理,IO密集型最好交给多线程来处理,此外进程的数量应该和cpu的核数保持一致。

线程
1、线程也叫轻量级进程,它是一个基本的CPU执行单元,也是程序执行过程中的最小单元,由线程ID、程序计数器、寄存器集合和堆栈共同组成。

2、线程的引入减小了程序并发执行时的开销,提高了操作系统的并发性能。

3、线程没有自己的系统资源。

   4、多任务可以由多进程完成,也可以由一个进程内的多线程完成,一个进程内的所有线程,共享同一块内存python中创建线程比较简单,导入threading模块,下面来看一下代码中如何创建多线程。

创建一个新线程:

import time
import threading

def f1(i):
    time.sleep(1)
    print(i)


if __name__ == '__main__':
    for i in range(5):
        t = threading.Thread(target=f1, args=(i,))
        t.start()
    print('start')  # 主线程等待子线程完成,子线程并发执行
    
# 每次数字顺序不一
# start
# 0
# 1
# 2
# 4
# 3
View Code

主线程从上到下执行,创建5个子线程,打印出'start',然后等待子线程执行完结束,如果想让线程要一个个依次执行完,而不是并发操作,那么就要使用join方法。

import time
import threading

def f1(i):
    time.sleep(1)
    print(i)

if __name__ == '__main__':
    li = []
    for i in range(5):
        t = threading.Thread(target=f1, args=(i,))
        t.start()
        li.append(t)
    for t in li:
        t.join()
    print('start')

# 每次数字顺序不一
# 0
# 4
# 3
# 2
# 1
# start
join

上面的代码不适用join的话,主线程会默认等待子线程结束,才会结束,还有一种守护线程,即子线程守护主线程,主线程结束守护线程也结束。

import time
import threading

def f1(i):
    time.sleep(1)
    print(i)

if __name__ == '__main__':

    for i in range(5):
        t = threading.Thread(target=f1, args=(i,))
        t.setDaemon(True)  #守护线程设置在start之前
        t.start()

    print('start')

# start
setDaemon

除此之外,自己还可以为线程自定义名字,通过 t = threading.Thread(target=f1, args=(i,), name='mythread{}'.format(i)) 中的name参数,除此之外,Thread还有一下一些方法

t.getName() : 获取线程的名称

t.setName() : 设置线程的名称 

t.name : 获取或设置线程的名称

t.is_alive() : 判断线程是否为激活状态

t.isAlive() :判断线程是否为激活状态

t.isDaemon() : 判断是否为守护线程

线程锁

死锁示例:

import threading,time

class MyThread(threading.Thread):

    def funcA(self):
        A.acquire()
        print(self.name,'got A',time.ctime())
        time.sleep(2)
        B.acquire()
        print(self.name,'got B',time.ctime())
        B.release()
        A.release()

    def funcB(self):
        B.acquire()
        print(self.name,'got A',time.ctime())
        time.sleep(1)
        A.acquire()
        print(self.name,'got B',time.ctime())
        A.release()
        B.release()
    def run(self):
        self.funcA()
        self.funcB()

li = []
A = threading.Lock()
B = threading.Lock()
for i in range(5):
    t = MyThread()
    li.append(t)
for j in li:
    j.start()
for k in li:
    k.join()

# Thread-1 got A
# Thread-1 got B
# Thread-1 got A
# Thread-2 got A
#线程阻塞,1与2都不先释放锁,就出现了死锁
# 在线程间共享多个资源的时候,如果两个线程分别占有一部分资源并且同时等待对方的资源,
# 就会造成死锁,因为系统判断这部分资源都正在使用,所有这两个线程在无外力作用下将一直等待下去。

解决方法:递归锁

import threading,time

class MyThread(threading.Thread):

    def funcA(self):
        r_lock.acquire()
        print(self.name,'got A',time.ctime())
        time.sleep(2)
        r_lock.acquire()
        print(self.name,'got B',time.ctime())
        r_lock.release()
        r_lock.release()

    def funcB(self):
        r_lock.acquire()
        print(self.name,'got A',time.ctime())
        time.sleep(1)
        r_lock.acquire()
        print(self.name,'got B',time.ctime())
        r_lock.release()
        r_lock.release()
    def run(self):
        self.funcA()
        self.funcB()

li = []
r_lock = threading.RLock()
for i in range(5):
    t = MyThread()
    li.append(t)
for j in li:
    j.start()
for k in li:
    k.join()

Lock如果多次获取锁的时候会出错,而RLock允许在同一线程中被多次acquire,但是需要用n次的release才能真正释放所占用的琐,一个线程获取了锁在释放之前,其他线程只有等待。

为了支持在同一线程中多次请求同一资源,python提供了“可重入锁”:threading.RLock。RLock内部维护着一个Lock和一个counter变量,counter记录了acquire的次数,从而使得资源可以被多次acquire

线程间通讯Event


Event是线程间通信最常见的机制之一,主要用于主线程控制其他线程的执行,主要用过wait,clear,set,这三个方法来实现的

红绿灯示例:

import time
import threading

def lighter():
    count = 0

    while 1:
        if count<30:
            if not event.is_set():
                event.set()
            print('33[32;1m绿灯33[1m')
        elif count<34:
            print('33[33;1m黄灯33[1m')
        elif count<60:
            event.clear()
            print('33[31;1m红灯33[1m')
        else:
            count = 0
        count+=1
        time.sleep(0.2)
def car(n):
    count =0
    while 1:
        event.wait()
        print('汽车【%s】通过'%n)
        count+=1
        time.sleep(1)
event = threading.Event()
l1 =threading.Thread(target=lighter)
l1.start()

c1 = threading.Thread(target=car,args=('奔驰',))
c1.start()

# 绿灯
# 汽车【奔驰】通过
# 绿灯
# 绿灯
# 绿灯
# 绿灯
# 黄灯
# 汽车【奔驰】通过
# 黄灯
# 黄灯
# 黄灯
# 红灯
# 红灯
# 红灯
import threading,time
class Boss(threading.Thread):
    def run(self):
        print("BOSS:今晚大家都要加班到22:00。")
        print(event.isSet())
        event.set()
        time.sleep(5)
        print("BOSS:<22:00>可以下班了。")
        print(event.isSet())
        event.set()
class Worker(threading.Thread):
    def run(self):
        event.wait()
        print("Worker:哎……命苦啊!")
        time.sleep(1)
        event.clear()
        event.wait()
        print("Worker:OhYeah!")
if __name__=="__main__":
    event=threading.Event()
    threads=[]
    for i in range(5):
        threads.append(Worker())
    threads.append(Boss())
    for t in threads:
        t.start()
    for t in threads:
        t.join()
例2
信号量(semaphore)

信号量用来控制线程并发数的,BoundedSemaphore或Semaphore管理一个内置的计数 器,每当调用acquire()时-1,调用release()时+1。

计数器不能小于0,当计数器为 0时,acquire()将阻塞线程至同步锁定状态,直到其他线程调用release()。(类似于停车位的概念)

BoundedSemaphore与Semaphore的唯一区别在于前者将在调用release()时检查计数 器的值是否超过了计数器的初始值,如果超过了将抛出一个异常。

import threading,time
class myThread(threading.Thread):
    def run(self):
        if semaphore.acquire():
            print(self.name)
            time.sleep(5)
            semaphore.release()
if __name__=="__main__":
    semaphore=threading.Semaphore(5)
    thrs=[]
    for i in range(100):
        thrs.append(myThread())
    for t in thrs:
        t.start()
View Code
队列

队列的方法:

q = queue.Queue(maxsize=0)  # 构造一个先进显出队列,maxsize指定队列长度,为0时,表示队列长度无限制。
 
q.join()        # 等到队列为kong的时候,在执行别的操作
q.qsize()       # 返回队列的大小 (不可靠)
q.empty()       # 当队列为空的时候,返回True 否则返回False (不可靠)
q.full()        # 当队列满的时候,返回True,否则返回False (不可靠)
q.put(item, block=True, timeout=None)   # 将item放入Queue尾部,item必须存在,参数block默认为True,表示当队列满时,会等待
                        # 为False时为非阻塞,此时如果队列已满,会引发queue.Full 异常。 可选参数timeout,表示会阻塞设置的时间,
                        # 如果在阻塞时间里 队列还是无法放入,则引发 queue.Full 异常
 
q.get(block=True, timeout=None)     #  移除并返回队列头部的一个值,可选参数block默认为True,表示获取值的时候,如果队列为空,则阻塞
                       #  阻塞的话若此时队列为空,则引发queue.Empty异常。 可选参数timeout,表示会阻塞设置的时间,
q.get_nowait()               #  等效于 get(item,block=False) 

队列的三种进出模式:

import queue

q= queue.Queue(3)
q.put(12)
q.put('hello')
q.put({'name':'alex'})


while 1:
    data = q.get()
    print(data)
    print('================')

######################################################
import queue

q= queue.LifoQueue(3)
q.put(12)
q.put('hello')
q.put({'name':'alex'})
q.qsize()


while 1:
    data = q.get()
    print(data)
    print('================')

#######################################################
import queue

q= queue.PriorityQueue()
q.put([2,12])
q.put([1,'hello'])
q.put([3,{'name':'alex'}])
q.qsize()


while 1:
    data = q.get()
    print(data[1])
    print('================')
View Code

生产者消费者模型:

def producer(num):
    for i in range(num):
        q.put(i)
        print('将{}添加到队列中'.format(i))
        time.sleep(1)


def consumer(num):
    count = 0
    while count < num:
        i = q.get()
        print('将{}从队列取出'.format(i))
        time.sleep(2)
        count += 1

q = queue.Queue(10)

t1 = threading.Thread(target=producer, args=(10,))
t1.start()

t2 = threading.Thread(target=consumer, args=(10,))
t2.start()
 
进程与线程的区别
一个程序至少有一个进程,一个进程至少有一个线程.(进程可以理解成线程的容器)
进程在执行过程中拥有独立的内存单元,而多个线程共享内存,从而极大地提高了程序的运行效率。
线程在执行过程中与进程还是有区别的。每个独立的线程有一个程序运行的入口、顺序执行序列和程序的出口。
但是线程不能够独立执行,必须依存在应用程序中,由应用程序提供多个线程执行控制。 
进程是具有一定独立功能的程序关于某个数据集合上的一次运行活动,进程是系统进行资源分配和调度的一个独立单位. 
线程是进程的一个实体,是CPU调度和分派的基本单位,它是比进程更小的能独立运行的基本单位.线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈)但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源. 
一个线程可以创建和撤销另一个线程;同一个进程中的多个线程之间可以并发执行.
View Code
协程

协程,又称微线程,协程执行看起来有点像多线程,但是事实上协程就是只有一个线程,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显,此外因为只有一个线程,不需要多线程的锁机制,也不存在同时写变量冲突。协程的适用场景:当程序中存在大量不需要CPU的操作时(IO)下面来看一个利用协程例子

import time
import queue

def consumer(name):
    print("--->ready to eat baozi...")
    while True:
        new_baozi = yield
        print("[%s] is eating baozi %s" % (name,new_baozi))
        #time.sleep(1)

def producer():

    r = con.__next__()
    r = con2.__next__()
    n = 0
    while 1:
        time.sleep(1)
        print("33[32;1m[producer]33[0m is making baozi %s and %s" %(n,n+1) )
        con.send(n)
        con2.send(n+1)

        n +=2


if __name__ == '__main__':
    con = consumer("c1")
    con2 = consumer("c2")
    p = producer()
yield
import gevent

import requests,time


start=time.time()

def f(url):
    print('GET: %s' % url)
    resp =requests.get(url)
    data = resp.text
    print('%d bytes received from %s.' % (len(data), url))

gevent.joinall([

        gevent.spawn(f, 'https://www.python.org/'),
        gevent.spawn(f, 'https://www.yahoo.com/'),
        gevent.spawn(f, 'https://www.baidu.com/'),
        gevent.spawn(f, 'https://www.sina.com.cn/'),

])

# f('https://www.python.org/')
#
# f('https://www.yahoo.com/')
#
# f('https://baidu.com/')
#
# f('https://www.sina.com.cn/')

print("cost time:",time.time()-start)
事件驱动

1、线性模式

传统的编程是如下线性模式的:

        开始--->代码块A--->代码块B--->代码块C--->代码块D--->......--->结束

        每一个代码块里是完成各种各样事情的代码,但编程者知道代码块A,B,C,D...的执行顺序,唯一能够改变这个流程的是数据。输入不同的数据,根据条件语句判断,流程或许就改为A--->C--->E...--->结束。每一次程序运行顺序或许都不同,但它的控制流程是由输入数据和你编写的程序决定的。如果你知道这个程序当前的运行状态(包括输入数据和程序本身),那你就知道接下来甚至一直到结束它的运行流程。

 对于事件驱动型程序模型,它的流程大致如下:

          开始--->初始化--->等待

         与上面传统编程模式不同,事件驱动程序在启动之后,就在那等待,等待什么呢?等待被事件触发。传统编程下也有“等待”的时候,比如在代码块D中,你定义了一个input(),需要用户输入数据。但这与下面的等待不同,传统编程的“等待”,比如input(),你作为程序编写者是知道或者强制用户输入某个东西的,或许是数字,或许是文件名称,如果用户输入错误,你还需要提醒他,并请他重新输入。事件驱动程序的等待则是完全不知道,也不强制用户输入或者干什么。只要某一事件发生,那程序就会做出相应的“反应”。这些事件包括:输入信息、鼠标、敲击键盘上某个键还有系统内部定时器触发。

2、事件驱动模型

通常,我们写服务器处理模型的程序时,有以下几种模型:

(1)每收到一个请求,创建一个新的进程,来处理该请求; 
(2)每收到一个请求,创建一个新的线程,来处理该请求; 
(3)每收到一个请求,放入一个事件列表,让主进程通过非阻塞I/O方式来处理请求

第三种就是协程、事件驱动的方式,一般普遍认为第(3)种方式是大多数网络服务器采用的方式 

示例:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>

</head>
<body>

<p onclick="fun()">点我呀</p>


<script type="text/javascript">
    function fun() {
          alert('约吗?')
    }
</script>
</body>

</html>
事件驱动之鼠标点击事件注册

在UI编程中,常常要对鼠标点击进行相应,首先如何获得鼠标点击呢? 两种方式:

1创建一个线程循环检测是否有鼠标点击

那么这个方式有以下几个缺点:

  1. CPU资源浪费,可能鼠标点击的频率非常小,但是扫描线程还是会一直循环检测,这会造成很多的CPU资源浪费;如果扫描鼠标点击的接口是阻塞的呢?
  2. 如果是堵塞的,又会出现下面这样的问题,如果我们不但要扫描鼠标点击,还要扫描键盘是否按下,由于扫描鼠标时被堵塞了,那么可能永远不会去扫描键盘;
  3. 如果一个循环需要扫描的设备非常多,这又会引来响应时间的问题; 
    所以,该方式是非常不好的。

2 就是事件驱动模型 

目前大部分的UI编程都是事件驱动模型,如很多UI平台都会提供onClick()事件,这个事件就代表鼠标按下事件。事件驱动模型大体思路如下:

  1. 有一个事件(消息)队列;
  2. 鼠标按下时,往这个队列中增加一个点击事件(消息);
  3. 有个循环,不断从队列取出事件,根据不同的事件,调用不同的函数,如onClick()、onKeyDown()等;
  4. 事件(消息)一般都各自保存各自的处理函数指针,这样,每个消息都有独立的处理函数; 

事件驱动编程是一种编程范式,这里程序的执行流由外部事件来决定。它的特点是包含一个事件循环,当外部事件发生时使用回调机制来触发相应的处理。

另外两种常见的编程范式是(单线程)同步以及多线程编程。

最初的问题:怎么确定IO操作完了切回去呢?通过回调函数 

1.要理解事件驱动和程序,就需要与非事件驱动的程序进行比较。实际上,现代的程序大多是事件驱动的,比如多线程的程序,肯定是事件驱动的。早期则存在许多非事件驱动的程序,这样的程序,在需要等待某个条件触发时,会不断地检查这个条件,直到条件满足,这是很浪费cpu时间的。而事件驱动的程序,则有机会释放cpu从而进入睡眠态(注意是有机会,当然程序也可自行决定不释放cpu),当事件触发时被操作系统唤醒,这样就能更加有效地使用cpu.
2.再说什么是事件驱动的程序。一个典型的事件驱动的程序,就是一个死循环,并以一个线程的形式存在,这个死循环包括两个部分,第一个部分是按照一定的条件接收并选择一个要处理的事件,第二个部分就是事件的处理过程。程序的执行过程就是选择事件和处理事件,而当没有任何事件触发时,程序会因查询事件队列失败而进入睡眠状态,从而释放cpu。
3.事件驱动的程序,必定会直接或者间接拥有一个事件队列,用于存储未能及时处理的事件。
4.事件驱动的程序的行为,完全受外部输入的事件控制,所以,事件驱动的系统中,存在大量这种程序,并以事件作为主要的通信方式。
5.事件驱动的程序,还有一个最大的好处,就是可以按照一定的顺序处理队列中的事件,而这个顺序则是由事件的触发顺序决定的,这一特性往往被用于保证某些过程的原子化。
6.目前windows,linux,nucleus,vxworks都是事件驱动的,只有一些单片机可能是非事件驱动的。
事件驱动注解

注意,事件驱动的监听事件是由操作系统调用的cpu来完成的

IO多路复用

基础:

1.用户空间和内核空间(用户态和内核态

内核态:就是系统的最高指令集,控制权限由cpu控制

用户态:最高指令为系统,用户操作控制系统,再又系统去执行操作内核态。 

2. 进程切换

为了控制进程的执行,内核必须有能力挂起正在CPU上运行的进程,并恢复以前挂起的某个进程的执行。这种行为被称为进程切换,这种切换是由操作系统来完成的。因此可以说,任何进程都是在操作系统内核的支持下运行的,是与内核紧密相关的。 

从一个进程的运行转到另一个进程上运行,这个过程中经过下面这些变化:

保存处理机上下文,包括程序计数器和其他寄存器。

更新PCB信息。

把进程的PCB移入相应的队列,如就绪、在某事件阻塞等队列。

选择另一个进程执行,并更新其PCB。

更新内存管理的数据结构。

恢复处理机上下文。 

注:总而言之就是很耗资源的

3.进程的阻塞

正在执行的进程,由于期待的某些事件未发生,如请求系统资源失败、等待某种操作的完成、新数据尚未到达或无新工作做等,则由系统自动执行阻塞原语(Block),使自己由运行状态变为阻塞状态。可见,进程的阻塞是进程自身的一种主动行为,也因此只有处于运行态的进程(获得CPU),才可能将其转为阻塞状态。当进程进入阻塞状态,是不占用CPU资源的。

4.文件描述符

文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念。 
文件描述符在形式上是一个非负整数。实际上,它是一个索引值,指向内核为每一个进程所维护的该进程打开文件的记录表。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符。在程序设计中,一些涉及底层的程序编写往往会围绕着文件描述符展开。但是文件描述符这一概念往往只适用于UNIX、Linux这样的操作系统。

5. 缓存 I/O

缓存 I/O 又被称作标准 I/O,大多数文件系统的默认 I/O 操作都是缓存 I/O。在 Linux 的缓存 I/O 机制中,操作系统会将 I/O 的数据缓存在文件系统的页缓存( page cache )中,也就是说,数据会先被拷贝到操作系统内核的缓冲区中,然后才会从操作系统内核的缓冲区拷贝到应用程序的地址空间。用户空间没法直接访问内核空间的,内核态到用户态的数据拷贝 

思考:为什么数据一定要先到内核区,直接到用户内存不是更直接吗?
缓存 I/O 的缺点: 

数据在传输过程中需要在应用程序地址空间和内核进行多次数据拷贝操作,这些数据拷贝操作所带来的 CPU 以及内存开销是非常大的。

I/O

对于一个network IO (这里我们以read举例),它会涉及到两个系统对象,一个是调用这个IO的process (or thread),另一个就是系统内核(kernel)。当一个read操作发生时,它会经历两个阶段:

 1、 等待数据准备 (Waiting for the data to be ready)

 2、 将数据从内核拷贝到进程中 (Copying the data from the kernel to the process)

记住这两点很重要,因为这些IO Model的区别就是在两个阶段上各有不同的情况。

阻塞I/O(blocking)

当用户进程调用了recvfrom这个系统调用,kernel就开始了IO的第一个阶段:准备数据。对于network io来说,很多时候数据在一开始还没有到达(比如,还没有收到一个完整的UDP包),这个时候kernel就要等待足够的数据到来。而在用户进程这边,整个进程会被阻塞。当kernel一直等到数据准备好了,它就会将数据从kernel中拷贝到用户内存,然后kernel返回结果,用户进程才解除block的状态,重新运行起来。
所以,blocking IO的特点就是在IO执行的两个阶段都被block了。

非阻塞I/O(non-blocking)

从图中可以看出,当用户进程发出read操作时,如果kernel中的数据还没有准备好,那么它并不会block用户进程,而是立刻返回一个error。从用户进程角度讲 ,它发起一个read操作后,并不需要等待,而是马上就得到了一个结果。用户进程判断结果是一个error时,它就知道数据还没有准备好,于是它可以再次发送read操作。一旦kernel中的数据准备好了,并且又再次收到了用户进程的system call,那么它马上就将数据拷贝到了用户内存,然后返回。
所以,用户进程其实是需要不断的主动询问kernel数据好了没有。

 注意:

      在网络IO时候,非阻塞IO也会进行recvform系统调用,检查数据是否准备好,与阻塞IO不一样,”非阻塞将大的整片时间的阻塞分成N多的小的阻塞, 所以进程不断地有机会 ‘被’ CPU光顾”。

  即每次recvform系统调用之间,cpu的权限还在进程手中,这段时间是可以做其他事情的。

      也就是说非阻塞的recvform系统调用调用之后,进程并没有被阻塞,内核马上返回给进程,如果数据还没准备好,此时会返回一个error。进程在返回之后,可以干点别的事情,然后再发起recvform系统调用。重复上面的过程,循环往复的进行recvform系统调用。这个过程通常被称之为轮询。轮询检查内核数据,直到数据准备好,再拷贝数据到进程,进行数据处理。

  需要注意,拷贝数据整个过程,进程仍然是属于阻塞的状态。

优点:能够在等待任务完成的时间里干其他活了(包括提交其他任务,也就是 “后台” 可以有多个任务在同时执行)。

缺点:任务完成的响应延迟增大了,因为每过一段时间才去轮询一次read操作,而任务可能在两次轮询之间的任意时间完成。这会导致整体数据吞吐量的降低。

IO multiplexing(IO多路复用)

当用户进程调用了select,那么整个进程会被block,而同时,kernel会“监视”所有select负责的socket,当任何一个socket中的数据准备好了,select就会返回。

这个时候用户进程再调用read操作,将数据从kernel拷贝到用户进程。


这个图和blocking IO的图其实并没有太大的不同,事实上,还更差一些。因为这里需要使用两个system call (select 和 recvfrom),而blocking IO只调用了一个system call (recvfrom)。

但是,用select的优势在于它可以同时处理多个connection。(多说一句。所以,如果处理的连接数不是很高的话,使用select/epoll的web server不一定比使用multi-threading + blocking IO的web server性能更好,可能延迟还更大。select/epoll的优势并不是对于单个连接能处理得更快,而是在于能处理更多的连接。)
在IO multiplexing Model中,实际中,对于每一个socket,一般都设置成为non-blocking,但是,如上图所示,整个用户的process其实是一直被block的。只不过process是被select这个函数block,而不是被socket IO给block。

注意1:select函数返回结果中如果有文件可读了,那么进程就可以通过调用accept()或recv()来让kernel将位于内核中准备到的数据copy到用户区。

注意2: select的优势在于可以处理多个连接,不适用于单个连接

I/O多路复用指通过一种机制,可以监视多个描述符,一旦某个描述符就绪(一般是读就绪或者写就绪),能够通知程序进行相应的读写操作。下面看一下 select,poll,epoll的介绍

select
  
select最早于1983年出现在4.2BSD中,它通过一个select()系统调用来监视多个文件描述符的数组,当select()返回后,该数组中就绪的文件描述符便会被内核修改标志位,使得进程可以获得这些文件描述符从而进行后续的读写操作。
select目前几乎在所有的平台上支持,其良好跨平台支持也是它的一个优点,事实上从现在看来,这也是它所剩不多的优点之一。
select的一个缺点在于单个进程能够监视的文件描述符的数量存在最大限制,在Linux上一般为1024,不过可以通过修改宏定义甚至重新编译内核的方式提升这一限制。
另外,select()所维护的存储大量文件描述符的数据结构,随着文件描述符数量的增大,其复制的开销也线性增长。同时,由于网络响应时间的延迟使得大量TCP连接处于非活跃状态,但调用select()会对所有socket进行一次线性扫描,所以这也浪费了一定的开销。
  
poll
  
poll在1986年诞生于System V Release 3,它和select在本质上没有多大差别,但是poll没有最大文件描述符数量的限制。
poll和select同样存在一个缺点就是,包含大量文件描述符的数组被整体复制于用户态和内核的地址空间之间,而不论这些文件描述符是否就绪,它的开销随着文件描述符数量的增加而线性增大。
另外,select()和poll()将就绪的文件描述符告诉进程后,如果进程没有对其进行IO操作,那么下次调用select()和poll()的时候将再次报告这些文件描述符,所以它们一般不会丢失就绪的消息,这种方式称为水平触发(Level Triggered)。
  
epoll
  
直到Linux2.6才出现了由内核直接支持的实现方法,那就是epoll,它几乎具备了之前所说的一切优点,被公认为Linux2.6下性能最好的多路I/O就绪通知方法。
epoll可以同时支持水平触发和边缘触发(Edge Triggered,只告诉进程哪些文件描述符刚刚变为就绪状态,它只说一遍,如果我们没有采取行动,那么它将不会再次告知,这种方式称为边缘触发),理论上边缘触发的性能要更高一些,但是代码实现相当复杂。
epoll同样只告知那些就绪的文件描述符,而且当我们调用epoll_wait()获得就绪文件描述符时,返回的不是实际的描述符,而是一个代表就绪描述符数量的值,你只需要去epoll指定的一个数组中依次取得相应数量的文件描述符即可,这里也使用了内存映射(mmap)技术,这样便彻底省掉了这些文件描述符在系统调用时复制的开销。
另一个本质的改进在于epoll采用基于事件的就绪通知方式。在select/poll中,进程只有在调用一定的方法后,内核才对所有监视的文件描述符进行扫描,而epoll事先通过epoll_ctl()来注册一个文件描述符,一旦基于某个文件描述符就绪时,内核会采用类似callback的回调机制,迅速激活这个文件描述符,当进程调用epoll_wait()时便得到通知。
select,poll,epoll

windows下只支持select,示例:

   
#*************************server.py
import socket
import select
sk=socket.socket()
sk.bind(("127.0.0.1",9904))
sk.listen(5)

while True:
    r,w,e=select.select([sk,],[],[],5)
    for i in r:
        # conn,add=i.accept()
        #print(conn)
        print("hello")
    print('>>>>>>')
    
#*************************client.py
import socket

sk=socket.socket()

sk.connect(("127.0.0.1",9904))

while 1:
    inp=input(">>").strip()
    sk.send(inp.encode("utf8"))
    data=sk.recv(1024)
    print(data.decode("utf8"))

请思考:为什么不调用accept,会反复print?

Answer:因为select是水平触发

select实现并发聊天示例:

#***********************server.py
import socket
import select
sk=socket.socket()
sk.bind(("127.0.0.1",8801))
sk.listen(5)
inputs=[sk,]
while True:
    r,w,e=select.select(inputs,[],[],5)
    print(len(r))

    for obj in r:
        if obj==sk:
            conn,add=obj.accept()
            print(conn)
            inputs.append(conn)
        else:
            data_byte=obj.recv(1024)
            print(str(data_byte,'utf8'))
            inp=input('回答%s号客户>>>'%inputs.index(obj))
            obj.sendall(bytes(inp,'utf8'))

    print('>>',r)

#***********************client.py

import socket
sk=socket.socket()
sk.connect(('127.0.0.1',8801))

while True:
    inp=input(">>>>")
    sk.sendall(bytes(inp,"utf8"))
    data=sk.recv(1024)
    print(str(data,'utf8'))

此处的Socket服务端相比与原生的Socket,他支持当某一个请求不再发送数据时,服务器端不会等待而是可以去处理其他请求的数据。但是,如果每个请求的耗时比较长时,select版本的服务器端也无法完成同时操作。而且select,实现的是一个伪并发。

selectors

selectors通过单线程实现并发,示例:

import selectors
import socket

sel = selectors.DefaultSelector()

def accept(sock, mask):
    conn, addr = sock.accept()  # 接收链接
    print('accepted', conn, 'from', addr)
    conn.setblocking(False)#设置为非阻塞
    sel.register(conn, selectors.EVENT_READ, read)#注册链接

def read(conn, mask):
    data = conn.recv(1000)  # 接受消息
    if data:
        print('echoing', repr(data), 'to', conn)
        conn.send(data)  # 返回消息
    else:
        print('closing', conn)
        sel.unregister(conn)
        conn.close()

sock = socket.socket()
sock.bind(('localhost', 1234))
sock.listen(100)
sock.setblocking(False)
sel.register(sock, selectors.EVENT_READ, accept)

while True:
    events = sel.select()
    for key, mask in events:
        callback = key.data
        callback(key.fileobj, mask)

selectors实现简单FTP文件上传下载(多用户同时上传下载):

import os,socket,selectors
BASE_DIR = os.path.dirname(os.path.abspath(__file__))

class FtpServer:

    def __init__(self):
        self.dic = {}  # 创建状态字典
        self.sel = selectors.DefaultSelector()  #创建selectors对象
        self.sock()
        self.main()


    def sock(self):
        '''创建socket对象'''
        s = socket.socket()
        s.bind(('127.0.0.1',8090))
        s.listen(10)
        s.setblocking(False)
        self.sel.register(s, selectors.EVENT_READ, self.accept)
        print('服务端已开启')

    def main(self):
        '''监听主函数'''
        while True:
            events = self.sel.select()
            for key, mask in events:
                callback = key.data
                callback(key.fileobj, mask)

    def accept(self,sock, mask):
        '''接收函数'''
        conn, addr = sock.accept()
        conn.setblocking(False)
        self.sel.register(conn, selectors.EVENT_READ, self.read)
        self.dic[conn] = {}

    def read(self, conn, mask):
        try:
            if not self.dic[conn] :
                data = conn.recv(1024).decode()
                cmd,filename,filesize = data.split()
                self.dic[conn]={'cmd': cmd, 'filename': filename,'filesize': int(filesize)}

                if cmd == 'put':
                    conn.send('100'.encode())

                if cmd == 'get':
                    file = os.path.join(BASE_DIR,'download',filename)
                    if os.path.exists(file):
                        fileSize = os.path.getsize(file)
                        info = '%s %s'%('200',fileSize) #文件存在
                        conn.send(info.encode())
                    else:
                        info = '%s %s'%('201',0) #文件不存在
                        conn.send(info.encode())
            else:
                if self.dic[conn].get('cmd',None):
                    cmd=self.dic[conn].get('cmd')
                    if hasattr(self, cmd):
                        func = getattr(self,cmd)
                        func(conn)
                    else:
                        print('指令错误')
                        conn.close()
                else:
                    print('指令错误')
                    conn.close()

        except Exception as e:
            print(e)
            self.sel.unregister(conn)
            conn.close()

    def put(self, conn):
        '''上传'''
        self.have_rec = 0
        fileName = self.dic[conn]['filename']
        fileSize = self.dic[conn]['filesize']
        file = os.path.join(BASE_DIR,'upload',fileName)
        recv_data = conn.recv(1024)
        self.have_rec += len(recv_data)
        with open(file, 'ab') as f:
            f.write(recv_data)
        if fileSize == self.have_rec:
            if conn in self.dic.keys():
                self.dic[conn] = {}   #置空字典

    def get(self,conn):
        '''下载'''
        filename = self.dic[conn]['filename']
        path = os.path.join(BASE_DIR,'download',filename)
        if conn.recv(1024).decode() == '300':
            with open(path, 'rb') as f:
                for line in f:
                    conn.send(line)
            self.dic[conn] = {}     #置空字典


if __name__ == '__main__':

    FtpServer()
server
import os,sys,socket,time
BASE_DIR = os.path.dirname(os.path.abspath(__file__))

class FtpClient:
    def __init__(self):
        self.port=('127.0.0.1',8090)
        self.sock()
        self.interact()

    def sock(self):
        '''创建socket对象函数'''
        try:
            self.sk = socket.socket()
            self.sk.connect(self.port)
            print('连接FTP服务器成功!')
        except Exception as e:
            print(e)

    def interact(self):
        '''客户端与服务端交互函数'''
        while True:
            cmd = input('>>>').strip()
            if cmd == 'q':
                break
            cmd,file = cmd.split()
            if hasattr(self, cmd):
                func = getattr(self, cmd)
                func(cmd,file)
            else:
                print('输入命令错误!')

    def put(self,cmd,file):

        if os.path.isfile(file):
            fileName= os.path.basename(file)
            fileSize = os.path.getsize(file)
            fileInfo ='%s %s %s'%(cmd,fileName,fileSize)
            self.sk.send(fileInfo.encode())
            recv_mes = self.sk.recv(1024).decode()
            # print('recvMes', recv_mes)
            have_send = 0
            if recv_mes == '100':
                with open(file, 'rb') as f:
                    while fileSize > have_send :
                        data = f.read(1024)
                        self.sk.send(data)
                        have_send += len(data)
                        self.show_process(have_send, fileSize)
                sys.stdout.write('
')
                print('%s文件上传成功!' % fileName)
        else:
            print('文件不存在')

    def get(self, cmd,file):
        info = '%s %s %s'%(cmd,file,'0')
        self.sk.send(info.encode())
        fileInfo = self.sk.recv(1024).decode()
        fileMes, fileSize = fileInfo.split()
        fileSize=int(fileSize)
        if fileMes == '200':       #文件存在
            self.sk.send('300'.encode())
            path = os.path.join(BASE_DIR,file)
            have_recv = 0
            with open(path, 'wb') as f:
                while have_recv < int(fileSize):
                    data = self.sk.recv(1024)
                    have_recv += len(data)
                    f.write(data)
                    self.show_process(have_recv, fileSize)
                sys.stdout.write('
')
                print('%s下载完成!' % file)
        else:
            print("文件不存在!")
    def show_process(self,have_send,file_size):
        '''显示进度条'''
        k = int((have_send / file_size * 100))
        space = (100 - k) * ' '
        flag = k * '>'
        sys.stdout.write('
|%s|  [%s%%]' % ((flag + space), k))
        sys.stdout.flush()
        time.sleep(0.2)

if __name__ == '__main__':

    FtpClient()
client
socketserver

ThreadingTCPServer实现的Soket服务器内部会为每个client创建一个 “线程”,该线程用来和客户端进行交互。首先来看一下继承关系图

socketserver搭建:

import SocketServer

class MyServer(SocketServer.BaseRequestHandler):

    def handle(self):
        pass

if __name__ == '__main__':
    server = SocketServer.ThreadingTCPServer(('127.0.0.1',8090), MyServer)
    server.serve_forever()

上述代码的内部调用流程为:

  • 启动服务端程序
  • 执行 TCPServer.__init__ 方法,创建服务端Socket对象并绑定 IP 和 端口
  • 执行 BaseServer.__init__ 方法,将自定义的继承自SocketServer.BaseRequestHandler 的类 MyRequestHandle赋值给 self.RequestHandlerClass
  • 执行 BaseServer.server_forever 方法,While 循环一直监听是否有客户端请求到达 ...
  • 当客户端连接到达服务器
  • 执行 ThreadingMixIn.process_request 方法,创建一个 “线程” 用来处理请求
  • 执行 ThreadingMixIn.process_request_thread 方法
  • 执行 BaseServer.finish_request 方法,执行 self.RequestHandlerClass()  即:执行 自定义 MyRequestHandler 的构造方法(自动调用基类BaseRequestHandler的构造方法,在该构造方法中又会调用 MyRequestHandler的handle方法)

实战:socketserver搭建实现FTP(实现用户注册登录、断点续传、简单命令)

部分主要代码:

import os
import pickle,configparser,time
import subprocess
import socketserver
from socket import *
from FtpServer.conf import settings

class MyServer(socketserver.BaseRequestHandler):
    # def __init__(self):
    #     pass
    def handle(self):
        while True:
            try:
                data = self.request.recv(1024).decode()
                if '|' in data:
                    cmd,argv= data.split('|')
                else:
                    cmd = data
                    argv = None
                self.process(cmd, argv)  # process处理接收的命令
            except Exception as e:
                print(e)
                break


    def post(self, argv=None):
        argv = eval(argv)
        file_info = pickle.loads(argv)  # 获取客户端传来的消息
        file_name = file_info['file_name']
        file_size = int(file_info['file_size'])
        file_path = os.path.join(settings.USER_HOME, self.user, 'upload', file_name)
        have_down = 0  # 已经上传的位置

        if os.path.isfile(file_path):
            self.request.sendall('exist'.encode())

            ret = self.request.recv(1024).decode()
            if ret == 'Y':  # 续传
                have_send = os.stat(file_path).st_size  # 获取已经上传文件的大小
                self.request.sendall(str(have_send).encode())
                if have_send ==file_size:
                    return
                else:
                    f = open(file_path, 'ab')  # 续传以a模式打开文件,
            else:
                f = open(file_path, 'wb')  # 不续传以w模式打开,

        else:
            self.request.sendall('N'.encode())  # 直接上传
            f = open(file_path, 'wb')

        while True:
            if have_down == file_size:  # 一旦接受到的内容等于文件大小,直接退出循环  
                break
            try:
                ret = self.request.recv(1024)
            except Exception as e:
                break
            f.write(ret)
            have_down += len(ret)

    def process(self, cmd, argv=None):
        '''使用反射处理客户端传过来的命令'''
        if hasattr(self, cmd):
            func = getattr(self, cmd)
            func(argv)
        else:
            if cmd.startswith('cd'):  # 处理cd命令
                argv = cmd.split(' ')[1]
                if argv =='..':
                    self.request.sendall((os.path.join(settings.USER_HOME)).encode())
                    os.chdir(os.path.join(settings.USER_HOME))
                    return
                else:
                    self.request.sendall(argv.encode())
                    os.chdir(argv)
                    return
            elif cmd.startswith('ls'): #处理ls命令
                i = pickle.dumps(os.listdir())
                self.request.sendall(i)
                return

            elif cmd.startswith('login'):
                user,pwd = argv.split(':')
                if self.checklogin(user,pwd):
                    self.user =user
                    return
            elif cmd.startswith('register'):
                user,pwd = argv.split(':')
                if self.checkregister(user, pwd):
                    self.request.sendall('ok'.encode())
            else:
                try:
                    data = subprocess.getoutput(cmd)  # subprocess处理其他命令
                    self.request.sendall(data.encode())
                    return
                except Exception as e:
                    print(e)

    def checkregister(self,username,password):
        '''校验注册信息'''
        while True:
            config = configparser.ConfigParser()
            config.read(settings.USER_INFO)
            if username in config.sections():
                self.request.sendall('exist'.encode)
            else:
                config.add_section(username)
                config.set(username,'password',password)
                config.set(username, 'space_size', '5000000')
                config.set(username, 'use_size', '0')
                config.write(open(settings.USER_INFO,'w'))
                os.mkdir(os.path.join(settings.USER_HOME, username))
                os.mkdir(os.path.join(settings.USER_HOME, username,'download'))
                os.mkdir(os.path.join(settings.USER_HOME, username, 'upload'))
                return 1

    def checklogin(self,username,password):
        '''校验登录数据是否一致'''
        while True:
            config = configparser.ConfigParser()
            config.read(settings.USER_INFO)
            if username in config.sections():
                pwd = config[username]['password']
                if pwd == password:
                    self.request.sendall('100'.encode())
                    print(username,'客户端验证通过!')
                    self.request.sendall((os.path.join(settings.USER_HOME,username)).encode())
                    #家目录路径返回给客户端
                    os.chdir(os.path.join(settings.USER_HOME,username)) #cd到客户家目录下
                    return 1
                else:
                    self.request.sendall('101'.encode())
                    break
            else:
                self.request.sendall('101'.encode())

    def get(self,argv =None):
        '''接收客户端发来的数据'''

        file_name = argv
        file_path = os.path.join(settings.USER_HOME, self.user, 'download', file_name)


        if os.path.exists(file_path):
            file_size = os.stat(file_path).st_size
            self.request.sendall('ok'.encode())
            file_info = {
                'file_name': argv,
                'file_size': file_size,
            }
            info = pickle.dumps(file_info)
            self.request.sendall(info)

            ret = self.request.recv(1024).decode()
            have_down = 0
            if ret == 'exist':  # 返回值
                # res = (self.request.recv(1024)).encode()
                have_down = int((self.request.recv(1024)).decode())  # 接收服务端已下载文件大小
                if have_down ==file_size:
                    return


            with open(file_path, 'rb') as f:
                f.seek(have_down)
                while True:
                    if have_down ==file_size:
                        break
                    else:
                        data =f.read(1024)
                        self.request.sendall(data)
                        have_down+=len(data)
                return

        else:
            self.request.sendall('no'.encode())
            return

    @classmethod
    def start(cls):
        '''启动服务器'''
        print('33[32;1m服务器已启动!33[1m')
        server = socketserver.ThreadingTCPServer((settings.IP, settings.PORT), MyServer)

        server.serve_forever()

if __name__=='__main__':
    MyServer.start()
server
import sys,os,hashlib,pickle,time
from socket import *
from FtpClient.conf import settings

class MyClient:
    def __init__(self):
        self.addr = (settings.IP,settings.PORT)
        self.start()
        self.c_d = ''            #用户当前路径

    def post(self,argv = None):  #传入文件名称
        '''上传文件'''
        if len(argv) ==0:
            print('请输入上传文件名称!')
            return
        print('上传前请确保文件在用户的upload文件夹下!')
        file_path = os.path.join(settings.USER_HOME,self.user,'upload',argv)
        if os.path.exists(file_path):   #判断文件是否存在
            file_size = os.stat(file_path).st_size
            file_info = {
                'file_name': argv,
                'file_size': file_size,
            }
            info = pickle.dumps(file_info)
            self.socket.sendall(('post|%s'%info).encode())  # 将上传的文件信息作为参数发给服务端

            ret = self.socket.recv(1024).decode()
            have_send = 0
            if ret =='exist':
                choice = input('文件已存在,是否续传?(Y/N)').strip()
                if choice.upper() =='Y':
                    self.socket.sendall('Y'.encode())
                    current_size= int(self.socket.recv(1024).decode()) #接收服务端已存文件大小
                    if current_size ==file_size:
                        print('文件完整不需重新上传')
                        return
                    else:
                        have_send =current_size
                else:
                    self.socket.sendall('N'.encode())

            with open(file_path,'rb') as f:
                f.seek(have_send)
                for line in f:
                    self.socket.sendall(line)
                    have_send += len(line)
                    self.show_process(have_send,file_size)
            sys.stdout.write('
')
    def show_process(self,have_send,file_size):
        '''显示进度条'''
        k = int((have_send / file_size * 100))
        space = (100 - k) * ' '
        flag = k * '>'
        sys.stdout.write('
|%s|  [%s%%]' % ((flag + space), k))
        sys.stdout.flush()
        time.sleep(0.2)


    def get(self,argv =None):
        '''下载文件'''
        if len(argv) == 0:
            print('请输入下载文件名称!')
            return
        print('下载前请确保服务端download下有该文件!')
        self.socket.sendall(('get|%s' % argv).encode())  # 将下载的文件名作为参数发给服务端
        ret = self.socket.recv(1024).decode()
        have_down = 0
        if ret=='ok':      #服务端存在文件,可下载
            file_info = pickle.loads(self.socket.recv(1024))  # 获取服务端传来的消息
            file_name = file_info['file_name']
            file_size = int(file_info['file_size'])
            file_path = os.path.join(settings.USER_HOME, self.user, 'download', file_name)
            if os.path.exists(file_path):             # 客户端存在文件
                self.socket.sendall('exist'.encode())
                choice = input('文件已存在,是否继续下载?(Y/N)').strip()
                if choice.upper() == 'Y':
                    have_down = os.stat(file_path).st_size  # 获取已经下载文件的大小
                    self.socket.sendall((str(have_down).encode()))
                    if have_down ==file_size:
                        print('文件完整不需重新下载!')
                        return
                    f = open(file_path, 'ab')     # 续传以a模式打开文件
                else:
                    self.socket.sendall((str(0).encode()))
                    f = open(file_path, 'wb')     # 不续传以w模式打开
            else:
                self.socket.sendall('reload'.encode())  # 重新下载
                f = open(file_path, 'wb')
            while True:
                if have_down == file_size:
                    break
                try:
                    ret = self.socket.recv(1024)
                except Exception as e:
                    break
                f.write(ret)
                have_down += len(ret)
                self.show_process(have_down, file_size)
            sys.stdout.write('
')
        else:
            print('所下载文件不存在')
            return

    def register(self):
        '''注册用户'''
        while True:
            user = input('请输入用户名:').strip()
            if len(user) ==0:continue
            password = input('请输入密码:').strip()
            if len(password) ==0:continue
            pd =hashlib.md5()
            pd.update(password.encode())
            pwd = pd.hexdigest()                                         #加密后的信息
            # pwd = password
            self.socket.sendall(('register|%s:%s'%(user, pwd)).encode())# 发送加密后的账户信息
            ret = self.socket.recv(1024).decode()
            if ret =='ok':
                print('注册成功,请登录!')
                os.mkdir(os.path.join(settings.USER_HOME, user))
                os.mkdir(os.path.join(settings.USER_HOME, user,'download'))
                os.mkdir(os.path.join(settings.USER_HOME, user, 'upload'))
                return 1
            else:
                print('注册用户名已存在!')

    def login(self):
        '''客户端用户登录'''
        try_times = 0
        while try_times < 3:
            user = input('请输入用户名:')
            self.user = user
            if len(user) == 0:
                continue
            password = input('请输入用密码:')
            if len(password) == 0:
                continue
            pd =hashlib.md5()
            pd.update(password.encode())
            pwd = pd.hexdigest() #加密后的信息
            # pwd = password
            self.socket.sendall('login|{}:{}'.format(user, pwd).encode())  # 发送加密后的账户信息
            ret = self.socket.recv(1024).decode()
            if ret == '100':
                print('登陆成功!')
                self.c_d = self.socket.recv(1024).decode()
                return 1
            else:
                print('用户或密码错误!')
                try_times += 1
        sys.exit('尝试太多次!')
    def interact(self):
        '''客户端与服务端交互'''
        print('服务器连接成功!')
        while True:
            choice = input("[%s]:"%self.c_d).strip()
            if len(choice) == 0:continue
            if '|' in choice:
                cmd,argv = choice.split('|')
            else:
                cmd = choice
                argv = None
            if hasattr(self,'%s'%cmd):
                func = getattr(self,'%s'%cmd)
                func(argv)
            elif cmd.startswith('cd'):
                self.socket.sendall(choice.encode())
                self.c_d = self.socket.recv(1024).decode()
                continue
            elif cmd.startswith('ls'):
                self.socket.sendall(choice.encode())
                ret = pickle.loads(self.socket.recv(1024))
                for i in ret:
                    print(i)
                continue
            else:
                self.socket.sendall(choice.encode())
                ret = self.socket.recv(1024)
                print(ret.decode())


    def start(self):
        '''启动主函数'''
        self.socket = socket(AF_INET,SOCK_STREAM)
        try:
            self.socket.connect(self.addr)
        except Exception as e:
            sys.exit("连接服务端错误:%s" % e)
        while True:
            msg = '''1.注册FTP
2.登录FTP
3.退出'''
            print('33[32;1m欢迎登录MyFtpClient!33[1m')
            print(msg)
            choice = input('>>>:').strip()
            if choice =='1':
                if self.register():
                    if self.login():
                        self.interact()
            elif choice =='2':
                if self.login():
                    self.interact()
            elif choice =='3':
                sys.exit()
            else:print('选项错误,请重新输入!')


if __name__=='__main__':
    client = MyClient()
    # client.interact()
client
原文地址:https://www.cnblogs.com/mocean/p/6286839.html