Python 多线程开发

线程简介

一、进程的优缺点

​ 在操作系统中,程序并不能单独运行,只有将程序装载到内存中,系统为它分配资源才能运行,而这种执行的程序就称之为进程。程序和进程的区别就在于:程序是指令的集合,它是进程运行的静态描述文本;进程是程序的一次执行活动,属于动态概念。在多道编程中,我们允许多个程序同时加载到内存中,在操作系统的调度下,可以实现并发地执行。正是这样的设计,大大提高了CPU的利用率。进程的出现让每个用户感觉到自己独享CPU,因此,进程就是为了在CPU上实现多道编程而提出的。

进程有很多优点,它提供了多道编程,让我们感觉我们每个人都拥有自己的CPU和其他资源,可以提高计算机的利用率。很多人就不理解了,既然进程这么优秀,为什么还要线程呢?其实,仔细观察就会发现进程还是有很多缺陷的,主要体现在两点上:

  1. 进程只能在一个时间干一件事,如果想同时干两件事或多件事,进程就无能为力了。
  2. 进程在执行的过程中如果阻塞,例如等待输入,整个进程就会挂起,即使进程中有些工作不依赖于输入的数据,也将无法执行。

例如:如果把我们上课的过程看成一个进程的话,那么我们要做的是耳朵听老师讲课,手上还要记笔记,脑子还要思考问题,这样才能高效的完成听课的任务。而如果只提供进程这个机制的话,上面这三件事将不能同时执行,同一时间只能做一件事,听的时候就不能记笔记,也不能用脑子思考,这是其一;如果老师在黑板上写演算过程,我们开始记笔记,而老师突然有一步推不下去了,阻塞住了,他在那边思考着,而我们呢,也不能干其他事,即使你想趁此时思考一下刚才没听懂的一个问题都不行,这是其二。所以,我们完全可以让听、写、思三个独立的过程,并行起来,这样很明显可以提高听课的效率。而实际的操作系统中,也同样引入了这种类似的机制——线程。

二、线程的特点

  • 线程被称作轻量级的进程。计算机的执行是以线程为单位的,即计算机的最小可执行单位是线程。

  • 进程是资源分配的基本单位;线程是可执行的基本单位,是可被调度的基本单位。

  • 线程不可以自己独立拥有资源。线程的执行,必须依赖于所属进程中的资源。

  • 进程中必须至少应该有一个线程。

  • 线程又分为用户级线程和内核级线程:

    1)用户级线程:对于程序员来说的,这样的线程完全被程序员控制执行,调度

    2)内核级下线程:对于计算机内核来说的,这样的线程完全被内核控制调度

  • 线程由三个部分组成:

    1)代码段

    2)数据段

    3)TCB (thread control block,类似于进程中的进程控制块)

三、线程和进程的比较

  1. 在 CPU 的切换上,进程要比线程慢的多;在 python 中,如果 IO 操作过多,最好使用线程。

  2. 在同一个进程内,所有线程共享这个进程的 pid,也就是说所有线程共享所属进程的所有资源和内存地址,但是线程间的资源不共享。

  3. 在同一个进程内,所有线程共享该进程中的全局变量;

  4. 关于守护进程和守护线程(注意:代码执行结束并不代表着程序结束):

    1)守护进程:要么自己正常结束,要么根据父进程的代码执行结束而结束

    2)守护线程:要么自己正常结束,要么根据父线程的执行结束而结束

  5. 在 CPython 解释器中,因为 GIL锁(全局解释器锁)的存在,在Cpython中,没有真正的线程并行,但是有真正的多进程并行。

  6. 在CPython中,IO密集用多线程,计算密集用多进程。

四、全局解释器锁GIL

Python代码的执行由Python虚拟机(也叫解释器主循环)来控制。Python在设计之初就考虑到要在主循环中,同时只有一个线程在执行。虽然 Python 解释器中可以“运行”多个线程,但在任意时刻只有一个线程在解释器中运行。对Python虚拟机的访问由全局解释器锁(GIL)来控制,正是这个锁能保证同一时刻只有一个线程在运行。

不管计算机时单核还是多核,在多线程运行的情况下,GIL锁限制在同一时间,只能有一个线程使用 CPU,虚拟机具体按如下方式调度线程:

​ a、设置 GIL;

  b、切换到一个线程去运行;

  c、限制线程执行固定数量的 bytecode 或者限制每个线程的执行时间(一般为5ms);

  d、把线程设置为睡眠状态;

  e、解锁 GIL;

  d、再次重复以上所有步骤。

只有在 CPython 解释器中才存在 GIL 锁问题,JPython、PyPy 等均不存在该问题。

线程的创建

方式一:直接创建

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from threading import Thread
import time

def func():
print("这是一个子线程")
time.sleep(2)

# 直接开启,线程的使用不需要有 mian 入口,但是为了编码规范,还是要统一加上
# t = Thread(target=func, args=())
# t.start()

if __name__ == '__main__':
t = Thread(target=func, args=())
t.start()

方式二:通过类继承方式开启

1
2
3
4
5
6
7
8
9
10
class MyThread(Thread):
def __init__(self):
super(MyThread, self).__init__()

def run(self):
print("启动子线程")

if __name__ == '__main__':
t = MyThread()
t.start()

线程和进程的对比

  • 时间方面,开启多个线程的速度比进程快
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def func():
pass


if __name__ == '__main__':
p_start = time.time()
for i in range(1000):
p = Process(target=func, args=())
p.start()
print("开启1000个进程的时间:%s" % (time.time() - p_start))

t_start = time.time()
for i in range(1000):
t = Thread(target=func, args=())
t.start()
print("开启1000个线程的时间:%s" % (time.time() - t_start))

# 开启1000个进程的时间:21.92893147468567
# 开启1000个线程的时间:0.17453241348266602
  • 是否共享内存空间和资源:进行间是相互独立的,多线程共享所属进程的资源和内存地址
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
from multiprocessing import Process
from threading import Thread
import os


def func(args):
print("我是 %s, 我的pid是 %s" % (args, os.getpid()))


if __name__ == '__main__':
print("我是 mian,我的pid是 %s" % os.getpid())
for i in range(3):
p = Process(target=func, args=("子进程",))
p.start()
for i in range(3):
t = Thread(target=func, args=("子线程",))
t.start()

"""
我是 mian,我的pid是 968
我是 子线程, 我的pid是 968
我是 子线程, 我的pid是 968
我是 子线程, 我的pid是 968
我是 子进程, 我的pid是 8256
我是 子进程, 我的pid是 4840
我是 子进程, 我的pid是 5984
"""
  • 操作全局变量方面:进程共享全局变量需要通过 Value 或者 Manger 以及锁机制的配合使用,线程可以直接共享全局变量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from threading import Thread

def func():
global num
num -= 1

if __name__ == '__main__':
num = 100
t_l = []
for i in range(10):
t = Thread(target=func, args=())
t.start()
t_l.append(t)
[t.join() for t in t_l]
print(num)

共享全局变量引申:GIL 的影响

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from threading import Thread
import time

def func():
global num
tmp = num
time.sleep(0.1)
num = tmp - 1


if __name__ == '__main__':
num = 100
t_l = []
for i in range(10):
t = Thread(target=func, args=())
t.start()
t_l.append(t)
[t.join() for t in t_l]
print(num) # 99

此时得到的结果并不是 90,这是由于 GIL 锁机制导致的。我们具体来分析一下原因:

当第一个线程创建并启动后,执行到 time.sleep() 时会阻塞睡眠等待,由于 GIL 在同一时间只允许有一个线程访问 CPU,且一般给线程执行的时间片只有5毫秒,所以在线程1睡眠阻塞的时候,会把线程1 踢出执行队列,去执行线程2;所有的线程都重复上述步骤,因此所有的线程都只执行到 sleep,后面的 num = tmp - 1 操作都未执行到。当10个线程都阻塞睡眠结束之后,GIL再重新逐个接入所有的线程继续向下执行,此时每个线程的 tmp 都还是原来的 100,并没有进行所谓的递减操作。

  • 守护线程和守护进程的比较:

    1)守护进程是随着主进程的代码的结束而结束

    2)守护线程是随着主线程执行的结束而结束

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from threading import Thread
import time

def func():
time.sleep(5)
print("哈哈哈哈哈")


def func1():
time.sleep(2)
print("我是守护线程")


if __name__ == '__main__':
p1 = Thread(target=func1, args=())
p1.daemon = True
p1.start()
p2 = Thread(target=func, args=())
p2.start()
print("我是主线程")

"""
我是主进程
我是守护线程
哈哈哈哈哈
"""

# 主线程输出 "我是主线程"后,主线程的代码已经执行结束了,但是守护线程还没有结束,因此守护线程并不是随着主线程代码执行结束而结束
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from threading import Thread
import time

def func1():
time.sleep(2)
print("我是守护线程")


if __name__ == '__main__':
p1 = Process(target=func1, args=())
p1.daemon = True
p1.start()

# 主线程开启子线程后就执行结束了,此时守护线程立刻也跟着结束了,因此守护线程是随着主线程执行的结束而结束

**注:**和多进程一样,线程也不是开的越多对CPU的利用率就越高,一般线程数量为 CPU 核数的五倍时,对CPU利用率比较高。

一、互斥锁

多线程的互斥锁(又称同步锁)和多进程中的用法一样。我们可以借助互斥锁来解决上述 GIL 对多线程使用全局变量的影响。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
from threading import Thread, Lock
import time


def func(l):
global num
l.acquire()
tmp = num
time.sleep(0.1)
num = tmp - 1
l.release()


if __name__ == '__main__':
num = 100
l = Lock()
t_l = []
for i in range(10):
t = Thread(target=func, args=(l,))
t_l.append(t)
t.start()
[t.join() for t in t_l]
print(num)

# 结果肯定是90,之前的代码没加锁,10个线程是异步执行。此时加了锁,10个线程想要操作变量num,必须同步的去操作

二、死锁

在进程和线程中都会存在死锁的问题。所谓死锁,是指两个或两个以上的进程或线程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。比如,要想上厕所,必须同时拿到厕纸和厕所两个资源,如果A抢到了厕所,但是没有拿到厕纸;B拿到了厕纸,但是没有抢到厕所,此时A和B都处于死锁状态,都在等待着对方释放资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from threading import Thread, Lock
import time

def person_1(l_tot, l_pap):
l_tot.acquire() # 第一个人拿到厕所了,把厕所门锁上
print("p1准备开始上厕所了")
time.sleep(3)
l_pap.acquire() # 第一个人上完了,准备要厕纸
print("p1拿到厕纸了")
time.sleep(0.5)
l_pap.release() # 第一个人先还厕纸
l_tot.release() # 第一个人把厕所让出来


def person_2(l_tot, l_pap):
l_pap.acquire() # 第二个人拿到厕纸
print("p2拿到厕纸了")
time.sleep(0.5)
l_tot.acquire() # 第二个人准备要厕所
print("p2准备开始上厕所了")
time.sleep(3)
l_tot.release() # 第二个人把厕所让出来
l_pap.release() # 第一个人还厕纸


if __name__ == '__main__':
l_tot = Lock() # 厕所资源
l_pap = Lock() # 厕纸资源
p1 = Thread(target=person_1, args=(l_tot, l_pap))
p2 = Thread(target=person_2, args=(l_tot, l_pap))
p1.start()
p2.start()

此时程序会一直阻塞等待,进入死锁状态。

三、递归锁

为了支持在同一线程中多次请求同一资源,python提供了可重入锁 RLock,即递归锁。这个RLock内部维护着一个Lock和一个counter变量,counter记录了acquire的次数,从而使得资源可以被多次acquire。直到一个线程所有的acquire都被release,其他的线程才能获得资源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
from threading import Lock
l = Lock()
l.acquire()
l.acquire()
print(123)
# 123 无法输出,互斥锁此时在阻塞状态

from threading import RLock
l = Lock()
l = RLock()
l.acquire()
l.acquire()
print(123)
# 使用递归锁不会出现死锁情况

递归锁形象的来理解,就是可以给资源上多个锁,但是如果是递归锁,就会有一把万能钥匙,可以开启所有的锁。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 递归锁解决上厕所死锁问题
from threading import Thread, RLock
import time

def person_1(l_tot, l_pap):
l_tot.acquire() # 第一个人拿到厕所了,把厕所门锁上
print("p1准备开始上厕所了")
time.sleep(3)
l_pap.acquire() # 第一个人上完了,准备要厕纸
print("p1拿到厕纸了")
time.sleep(0.5)
l_pap.release() # 第一个人先还厕纸
l_tot.release() # 第一个人把厕所让出来


def person_2(l_tot, l_pap):
l_pap.acquire() # 第二个人拿到厕纸
print("p2拿到厕纸了")
time.sleep(0.5)
l_tot.acquire() # 第二个人准备要厕所
print("p2准备开始上厕所了")
time.sleep(3)
l_tot.release() # 第二个人把厕所让出来
l_pap.release() # 第一个人还厕纸


if __name__ == '__main__':
l_tot = l_pap = RLock()
p1 = Thread(target=person_1, args=(l_tot, l_pap))
p2 = Thread(target=person_2, args=(l_tot, l_pap))
p1.start()
p2.start()

使用递归锁的话,即便对方把厕所锁起来了不给进,我也可以通过万能钥匙开门进去。同样的,即使我拿到了厕纸,对方也可以从我这边再把厕纸拿走。至于谁先去上厕所,就要看谁先把两个资源都拿到手了。

补充:

  • 在同一个线程内,递归锁可以无止尽的acquire,但是互斥锁不行
  • 在不同的线程内,递归锁是保证只能被一个线程拿到钥匙,然后无止尽的 acquire,其他线程等待

信号量

多线程的信号量机制和多进程相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from threading import Semaphore, Thread
import time


def func(sem, i):
sem.acquire()
print('第%s个人进入屋子' % i)
time.sleep(2)
print('第%s个人离开屋子' % i)
sem.release()


sem = Semaphore(5) # 允许配5把钥匙
for i in range(20):
t = Thread(target=func, args=(sem, i))
t.start()

事件

多线程的事件机制和多进程相同。

1
2
3
4
event.isSet():返回event的状态值;
event.wait():如果 event.isSet()==False将阻塞线程;
event.set(): 设置event的状态值为True,所有阻塞池的线程激活进入就绪状态, 等待操作系统调度;
event.clear():恢复event的状态值为False

使用事件机制模拟数据库连接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
from threading import Thread, Event
import time
import random


def conn_mysql(e, i):
count = 1
while 1:
if e.is_set(): # 如果为True,就是可以连接上数据库
break
if count > 3:
print('连接超时')
return
print('第%s个人正在尝试第%s次连接!' % (i, count))
e.wait(0.5) # 在这里阻塞等待0.5秒,模拟用户连接时的等待
count += 1
print('第%s个人连接成功' % i)


def check_mysql(e):
print('\033[45m 数据库正在维护 \033[0m') # 让数据库初始状态处于维护状态,默认所有用户连接不上
time.sleep(random.randint(1, 2)) # 随机1秒或2秒,如果随机1秒的话,用户就可以连接上,2秒就连接不上
e.set() # 将e.is_set()设为True


if __name__ == '__main__':
e = Event()
t = Thread(target=check_mysql, args=(e,))
t.start()
for i in range(10): # 产生10个线程都去尝试连接数据库
t1 = Thread(target=conn_mysql, args=(e, i))
t1.start()

条件

多线程中,条件机制是让程序员自己去调度线程的一个机制,它包含以下方法:

  • acquire() 获得条件资源
  • release() 释放条件资源
  • wait() 让线程阻塞住
  • notify(int) 是指给wait发一个信号,让wait变成不阻塞;int是指,你要给多少给wait发信号

Python提供的Condition对象提供了对复杂线程同步问题的支持。Condition被称为条件变量,除了提供与Lock类似的acquire和release方法外,还提供了wait和notify方法。线程首先acquire一个条件变量,然后判断一些条件。如果条件不满足则wait;如果条件满足,进行一些处理改变条件后,通过notify方法通知其他线程,其他处于wait状态的线程接到通知后会重新判断条件。不断的重复这一过程,从而解决复杂的同步问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
from threading import Condition, Thread


def func(con, i):
con.acquire()
con.wait() # 所有线程都阻塞在这里,因为还没制造出钥匙
print('在第%s个循环里' % i)
con.release() # 并不是还钥匙,而是把钥匙扔掉


if __name__ == '__main__':
con = Condition()
for i in range(10):
t = Thread(target=func, args=(con, i))
t.start()
while 1:
num = int(input('>>>'))
con.acquire()
con.notify(num) # 手动输入制造几把钥匙,相当于发一个信号,允许几个线程可以执行了
con.release()

定时器

定时器,指定n秒后执行某个操作。主要包含以下几个参数:

  • time:睡眠的时间,以秒为单位
  • func:睡眠时间之后,需要执行的任务
1
2
3
4
5
6
7
8
from threading import Timer


def func():
print('开始执行任务')


Timer(3, func).start()

队列

线程中的队列只能在同一个进程中使用。它的用法和进程中的队列相似,它有以下四种模式:

  • queue.Queue() 先进先出
1
2
3
4
5
6
7
q = queue.Queue()
q.put(1)
q.put("a")
q.put([1, 2, 3])
print(q.get()) # 1
print(q.get()) # a
print(q.get()) # [1,2,3]
  • queue.LifoQueue() 后进先出
1
2
3
4
5
6
7
q = queue.LifoQueue()
q.put(1)
q.put("a")
q.put([1, 2, 3])
print(q.get()) # [1,2,3]
print(q.get()) # a
print(q.get()) # 1
  • queue.PriorityQueue() 优先级队列

    1)q.put() 接收的是一个元组,元组中第一个参数是表示当前数据的优先级;元组中第二个参数是需 要存放到队列中的数据

    2)优先级的比较(首先保证整个队列中,所有表示优先级的东西类型必须一致)
    如果都是 int,比数值的大小
    如果都是 str,比较字符串的大小(从第一个字符的ASCII码开始比较)

1
2
3
4
5
6
7
q = queue.PriorityQueue()
q.put((1, "a"))
q.put((2, 1))
q.put((3, [1, 2, 3]))
print(q.get()) # (1, 'a')
print(q.get()) # (2, 1)
print(q.get()) # (3, [1, 2, 3])

线程池

在python中,concurrent.futures 模块为我们提供了高度封装的异步调用接口,因此我们可以通过该模块来使用进程池和线程池。(注:该模块提供的线程池和进程池都是异步执行的)

一、模块介绍

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
#1 介绍
concurrent.futures模块提供了高度封装的异步调用接口
ThreadPoolExecutor:线程池,提供异步调用
ProcessPoolExecutor: 进程池,提供异步调用
Both implement the same interface, which is defined by the abstract Executor class.

#2 基本方法
#submit(fn, *args, **kwargs)
异步提交任务

#map(func, *iterables, timeout=None, chunksize=1)
取代for循环submit的操作

#shutdown(wait=True)
相当于进程池的pool.close()+pool.join()操作,是指不允许再继续向池中增加任务,然后让父进程(线程)等待池中所有进程执行完所有任务。
wait=True,等待池内所有任务执行完毕回收完资源后才继续
wait=False,立即返回,并不会等待池内的任务执行完毕
但不管wait参数为何值,整个程序都会等到所有任务执行完毕
submit和map必须在shutdown之前

#result(timeout=None)
取得结果

#add_done_callback(fn)
回调函数

二、线程池的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from concurrent.futures import ThreadPoolExecutor

def func(n):
sum = 0
for i in range(n + 1):
sum += i ** 2
return sum


if __name__ == '__main__':
pp = ThreadPoolExecutor(5) # 线程池中允许有5个线程同时工作
pp_l= list()
for i in range(15):
s = pp.submit(func, i)
pp_l.append(s)
pp.shutdown()

[print(s.result()) for s in pp_l] # 取出返回值

三、进程池的使用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from concurrent.futures import ProcessPoolExecutor


def func(n):
sum = 0
for i in range(n + 1):
sum += i ** 2
return sum


if __name__ == '__main__':
pp = ProcessPoolExecutor(5) # 线程池中允许有5个进程同时工作
pp_l = list()
for i in range(15):
s = pp.submit(func, i)
pp_l.append(s)
pp.shutdown()

[print(s.result()) for s in pp_l] # 取出返回值

四、使用map提交多任务

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
from concurrent.futures import ThreadPoolExecutor
import time

def func(num):
sum = 0
# time.sleep(5)
# print(num) # 异步的效果
for i in range(num):
sum += i ** 2
return sum

t = ThreadPoolExecutor(20)

# 下列代码是用map的方式提交多个任务,对应 拿结果的方法是__next__() 返回的是一个生成器对象
res = t.map(func,range(1000))
t.shutdown()
print(res.__next__())
print(res.__next__())
print(res.__next__())
print(res.__next__())


# 下列代码是用for + submit提交多个任务的方式,对应拿结果的方法是result
# res_l = []
# for i in range(1000):
# re = t.submit(func,i)
# res_l.append(re)
# # t.shutdown()
# [print(i.result()) for i in res_l]
# 在Pool进程池中拿结果,是用get方法。 在ThreadPoolExecutor里边拿结果是用result方法

五、回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 不管是ProcessPoolExecutor的进程池  还是Pool的进程池,回调函数都是父进程调用的,和子进程没有关系。
# 线程池中的回调函数是子线程调用的,和父线程没有关系
from concurrent.futures import ProcessPoolExecutor
import os

def func(num):
sum = 0
for i in range(num):
sum += i ** 2
return sum


def call_back_fun(res):
print(os.getpid())
print(res)
print(res.result())


if __name__ == '__main__':
print(os.getpid())
t = ProcessPoolExecutor(20)
for i in range(1000):
t.submit(func, i).add_done_callback(call_back_fun)
t.shutdown()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from threading import Thread
from threading import current_thread
from concurrent.futures import ThreadPoolExecutor
import time
def func(i):
sum = 0
sum += i
time.sleep(1)
print('这是在子线程中',current_thread())
return sum

def call_back(sum):
time.sleep(1)
print('这是在回调函数中',sum.result(),current_thread()) # current_thread 用于打印当前线程的信息


if __name__ == '__main__':
t = ThreadPoolExecutor(5)
for i in range(10):
t.submit(func,i).add_done_callback(call_back)
t.shutdown()
print('这是在主线程中',current_thread())

线程池和进程池中的回调函数是谁在调用:

  • 线程池中的回调函数是子线程调用的,和父线程没有关系
  • 进程池中的回调函数是父进程调用的,和子进程没有关系

Python 多线程开发
https://clark-cdc.github.io/2019/05/20/0016-线程/
作者
clark
发布于
2019年5月20日
许可协议