python多线程处理

Posted 木土雨成测试员

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了python多线程处理相关的知识,希望对你有一定的参考价值。

在讲这个多线程之前,我先讲一个仍苹果的故事

这个故事呢,他跟我们今天要讲的这个技术息息相关,大家在看故事的时候呢,要思考一下它与多线程有什么关系
那这个故事呢,是讲一个杂技演员,他有一个非常重要的技能是扔苹果,这个技能是这样的,他可以在左手中把这个苹果扔出,那在右手边他会接到这个苹果,如此循环往复,左手扔出,右手接到,这个就是它的一个技能;那如果这个技能练的熟练的话,他可以扔三个苹果,这三个苹果是怎么扔出的呢,假设此时是苹果一,那他把这个苹果从左边扔出,右边接到,此时再有一个苹果二,苹果二扔出,右手接到,那还有个苹果三,苹果三扔出,右手接到,他会不停的轮询的去扔这三个苹果,这个过程呢,就像我画的这样,在不停的轮询的去扔,轮询的去接,如同一个简单的python函数一样


def main():
    print("仍第一个苹果")

if __name__ == '__main__':
    main()

那凭借着这个技能呢,演员吸引了非常多的观众,这样的话呢,由于观众的数量越来越多,那么他就会开启了一个自己的人生巅峰,创办了一个马戏团,那么当观众越来越多的时候呢,一个演员已经不能胜任这个取悦观众的任务,让他召集了越来越多的演员,帮助他去完成这件事情。

那此时这个故事呢,我也就讲完了,那演员的故事,给了我们一个很大的启发,那其实扔苹果的这个过程,就相当于在执行线程的过程
如果我们定一个非常简单的函数,也就是一个main函数,那这个函数呢,本质上就是一个线程,在执行他的时候,相当于把这个线程来再扔,如果我们定义了多个线程,比如说我额外增加两个线程,再加上一个主线程,它是三个线程,当Python去执行这三个线程的时候呢,就相当于在扔三个苹果,三个线程会被轮询的执行,谁也不会在空中多停留一会儿,这个表述非常的重要,要注意有三个线程在被轮询的执行
那演员在轮询的去扔着三个线程,那苹果一作为第一个线程,它会被执行一点儿,然后,去执行苹果二,苹果二也被执行一点儿,再执行苹果三,苹果三也被执行一点儿,那此时我又该扔苹果一,苹果一继续去执行剩下的部分,苹果二也继续执行剩下部分,苹果三也继续被执行剩下部分,它就像耍杂技一样,在不停的轮询执行每一个线程,线程也会由于在不停的轮训,最终会执行完毕,所以这个执行过程,你可以把它比喻成我的扔苹果,每一次扔呢,就是在执行线程的中的一个小部分
那我可以拿代码去演示一下这个过程,那首先我定一个非常简单的main函数,这个main函数就做一次打印,打印了我去扔一个苹果,如上

当打印完成之后呢,我在代码中去执行这个,得到的,就是去扔第一个苹果,扔一个苹果

那此时我们想让技能,变多,我们给他开启第二个线程和第三个线程,内容分别是仍第二个苹果,仍第三个苹果,实现这个技能呢我们使用threading这个库,我们把它导入;那怎么把这个技能赋予这个thread呢,我们可以让他的target参数等于我们的task,也就是我们创建了一个线程,我们使用start()函数去开启这个线程

import threading

def task():
    print("仍第二个苹果")

def task2():
    print("仍第三个苹果")

def main():
    #threading.Thread创建了一个线程
    Thread1 = threading.Thread(target=task())
    #让线程执行
    Thread1.start()
    Thread2 = threading.Thread(target=task2())
    # 让线程执行
    Thread2.start()
    print("仍第一个苹果")

if __name__ == '__main__':
    main()
D:\\code\\tips\\venv\\Scripts\\python.exe D:/code/tips/test_20221017.py
Hello World!
仍第二个苹果
仍第三个苹果
仍第一个苹果

Process finished with exit code 0

经过这样一段操作,我们就发现它具备了三个苹果,分别是主线程的一个苹果,和子线程的第二个苹果,还有第二个子线程的第三个苹果,

当我们执行的时候,你会发现,三个苹果都被扔出了,也就是先扔了第二个,然后扔第三个,然后最后扔了第一个,那这样的话我们就可以理解为该演员可以具备了这扔苹果的技能,扔苹果的技能也就是我们多线程的一个用法。

刚强调过一个事情,Python在执行多线程的时候,其实是在轮询的去执行
线程一执行完一点儿之后呢,会执行线程二,线程二执行一点儿之后,会执行线程三,也就是,线程一没有执行完,再去执行线程一,再执行线程二,再执行线程三,然后再去执行线程一,线程二、线程三,他是这样不停的轮询的去跑,就像苹果在轮询的被扔一样

一个比较复杂的例子,我们换成一个时间,我们假设在执行一个非常大的任务,睡眠时间变成5秒

def task():
    time.sleep(5)

def main():
    start_time = time.time()
    #threading.Thread创建了一个线程
    Thread1 = threading.Thread(target=task)
    Thread2 = threading.Thread(target=task)
    # 让线程执行
    Thread1.start()
    Thread2.start()
    end_time = time.time()
    print(end_time-start_time)


if __name__ == '__main__':
    main()
D:\\code\\tips\\venv\\Scripts\\python.exe D:/code/tips/test_20221017.py
0.0009975433349609375

Process finished with exit code 0

我们就能去统计出,总共这些线程执行的时间,我们运行一下,会发现,它的这个些时间,立马被打印了出来,我再执行一遍,这个时间先被打印,后续这两个线程依旧在跑

所以说这说明了一个现象,当我们去做执行的时候呢,这个主线程跟子线程他们之间是在轮询的被执行的,你计算时间的这个代码,他是不会等你这两个时间执行完的,比如说线程一执行了五秒,线程二执行了五秒,计算时间这个代码不会等线程二执行完才往下去跑,它会在你开始线程一那一刻就执行,它不会等这两个睡眠时间完成的

那怎么让它去等呢,我们就需要用到一个小技巧,我让线程一在执行的时候,加一个join,线程二在执行的时候,也加一个join,那join是什么作用呢,它可以让调用的线程或者让其他线程等待自己执行完成,那这样的话,如果开启了join,线程一没有执行完毕,线程二没有执行完毕的话,主线程是不会往下走的,那利用这个特性,我们在执行的时候,会发现打印时间这个代码,它会等这五秒执行完毕才会打印

import threading
import time


def task():
    time.sleep(5)

def main():
    start_time = time.time()
    #threading.Thread创建了一个线程
    Thread1 = threading.Thread(target=task)
    Thread2 = threading.Thread(target=task)
    # 让线程执行
    Thread1.start()
    Thread2.start()
    Thread1.join()
    Thread2.join()
    end_time = time.time()
    print(end_time-start_time)


if __name__ == '__main__':
    main()
D:\\code\\tips\\venv\\Scripts\\python.exe D:/code/tips/test_20221017.py
5.010531187057495

Process finished with exit code 0

我们可以这样去想,我们再去扔苹果123的时候,如果苹果二和三设置了join,那扔苹果二和三的时候,就禁止去仍苹果一,那此时空中就只有二和三,当二和三已经执行完,运行完毕之后,我此时再扔苹果一,这个就是这样一个过程。

Python作为一个编程语言,它有一个天生的特性叫GIL锁
什么叫GIL,就是你的多线程是一个假象,就像我在扔苹果一样,我开启了多线程,其实本质上就是一个CPU在忙碌,那之前讲了一个故事,当演员这个表演的非常好的时候,开起来给马戏团的时候,招了其他的演员,那这个过程本质上是代表有三个CPU在同时的去跑,我的空中可以同时出现三个苹果,但是Python的多线程就相当于一个演员一样,只能在空中出现一个苹果,这个东西我们称之为多线程,它的运行叫并发

那么,我拥有三个CPU,在空中同时出现三个苹果的时候呢,这个叫多进程,那它的运行叫并行,我可以同时有三个苹果在空中,三个东西在运行,这叫并行,那如果空中只能有一个,这个叫并发,

那由于Python GIL锁的存在,即使你实现了多线程,你也只能让一个苹果在空中,不能让三个苹果同时在空中,那GIL所是Python的一个机制,为什么要这么去实现呢,主要目的之一是为了保证线程安全
也就是说Python有一个GIL锁,那GIL锁是干嘛呢,是让线程在同一时刻,只能有一个苹果在运行,也就是组织同一个进程下多个线程同时被执行,所以说很多人在说,Python的线程是一个假线程,就是在说这件事情,我们在同一时刻只能有一个苹果在空中,那如果你用其他语言,它就不是这样,他可能有多个苹果,可能一个演员他都两个苹果在空中,那么由于我们Python的存在,它只能有一个苹果在空中,那多进程就不一样了,多进程他有多个苹果在空中

那好,那这样的话呢,我再去讲这个事情,那当我用time.sleep的时候呢,会发现执行时间是多少呢,是五秒,但这个东西大家都能理解,因为我用的是多线程,我开起了两个线程,两个线程在在轮询的被执行,所以说它的时间是五秒,这两个线程在轮询的被执行,那轮询的条件是什么,为什么要可以被轮询呢,是因为我用的是sleep,睡眠代表可以让出我的执行时间,我只有用了睡眠之后,这两个才能被轮询,我们可以试想一下,如果我用的不是睡眠,我用的是加减操作,那这个过程是不能被轮询的,那执行时间也不会说像现在这样是五秒,那这个过程可以这样去想,如果这个任务中有类似于睡眠操作,那这个任务对于演员来说它是不那么重要的,

如果你的任务中有睡眠,你的苹果代表我是可以睡眠,我可以让的话,那演员可以把它扔出去的时候呢,再去扔第一个苹果的时候呢,就是把它扔出去的时候呢,那第二个苹果他也可以继续去扔,也可以去扔第三个,因为你本身并没有那么重要,所以说我可以去轮询其它的任务,那如果你的代码中没有类似于睡眠这样一个操作,如果你是不停的在做加法运算的话,那说明你这个苹果是非常重要的,演员在扔你的时候呢,是不敢扔其他的,如果你把它换成加法操作,那演员认为你的这个苹果是非常重要,所以说他会只扔你,不扔其他的苹果,这个操作就导致了Python线程运行的时候呢,可能不会那么高效,因为如果你的线程里面全是Java操作,那演员的脑子是转不过来的,他是只能去扔你自己,只有把你这个任务全都扔完之后,才能去仍第二个,才能扔第三个,所以这个东西是一个非常知道的概念,那我们怎么去校验他呢,我们刚才可以看到,当我用睡眠的时候,这个时间是可以被大大减少,那如果把这个睡眠时间换成了一个,频繁的加法操作,演员此时他是不会去,执行其他事情,只能执行完你之后再去执行其他任务。
比如说我当a不等于9999乘以一个999的时候,那我就a在不停地进行一次加法操作,我直接a加等于一,那如果我们可以试想一下,当我这么去计算的时候,我们运行时间我们可以做一个统计


import threading
import time


def task():
    a = 0
    while a != 9999*9999:
        a += 1

def main():
    start_time = time.time()
    #threading.Thread创建了一个线程
    Thread1 = threading.Thread(target=task)
    Thread2 = threading.Thread(target=task)
    # 让线程执行
    Thread1.start()
    Thread2.start()
    Thread1.join()
    Thread2.join()
    end_time = time.time()
    print(end_time-start_time)


if __name__ == '__main__':
    main()
D:\\code\\tips\\venv\\Scripts\\python.exe D:/code/tips/test_20221017.py
13.004317045211792

Process finished with exit code 0

我们可以稍等一下,这个运行过程有点长,那用了12秒左右的时间,我们可以把这个过程记录下来,也就是说当我开启两个线程的时候,去计算这个数字9999乘9999,那由于我们开启的是两个线程,所以说这两个线程,它都在计算这个数字,也就是说右边这个线程,他也进行了999乘以一个999的计算,那总共花费的时间是多少呢,是12秒,那我们可以做一个假设,如果我们把这两个线程换成一个线程的话,那让他去计算什么呢,
去计算,这两个任务的加和,也就是999乘一个999,这是左边的任务再加上右边的任务9999A999乘以一个A999,我们可以大胆的猜测一下,这两个任务之间执行时间会不会有明显的差异,左边执行的时间会不会比这种单线程执行时间的效率更高呢,它的执行时间会不会高出非常多呢,我们可以猜测一下,好,那我运行一下,让你们去看一下结果,我们把这个操作,换成单线程,怎么换呢,其实也很简单,我们把多线程的代码给它删掉,然后最终只执行单线程,也就是在这个主线程中,我去运行这个循环操作

import time

def main():
    start_time = time.time()
    a = 0
    while a != 9999*9999*2:
        a += 1
    end_time = time.time()
    print(end_time-start_time)


if __name__ == '__main__':
    main()
D:\\code\\tips\\venv\\Scripts\\python.exe D:/code/tips/test_20221017.py
15.158465147018433

Process finished with exit code 0

那由于我们的任务,此时我要加上把左边儿的加上右边的,所以说我乘以了一个二,
就代表他执行了两个线程的任务,当我这么去执行的时候,我们可以运行一下,看一下它的一个运行时间,也是稍微的等一下你会发现,他其实也是12秒左右,这可以说明一个什么道理呢,当我们再去计算这种费脑子的操作的时候,也就是在计算加减乘除这种操作并没有用sleep的时候,你多线程它本质上其实就是在,排队,你一线程一执行完才能执行线程,二线程一执行完才能见执行完成,而此时的演员,他是没有,他是这个脑子转不过来,它就相当于把两个线程给他排起来,然后执行的,
所以说导致效果就是,如果你在计算这种费脑子的操作,你两个线程在跑,它本质上其实就是,相当于先执行线程一再执行线程二,它并不能做轮询,那跟你在用一个线程跑的结果是一样的,那这个呢,就是GIL锁的一个机制,但是呢,如果我们在代码中存在像sleep,等待这种操作,那他就会再称你执行Sleep的时候呢,在空中去抛另一个线程,也就是去执行一个线程,那只有当你去用sleep的时候,才能才能去切换,也正好说明了,那我们这个Python多线程的一个特殊性

在什么情况下可以被轮询,在什么情况下不可以被轮询,这个希望大家能记住,那在多进程的概念中,如果你们有了解的话,多进程其实是在同时的扔两个苹果,
那么多线程和多进程他们两个的优势是什么呢,多线程它是一个演员在不停的扔多个苹果,那多线程之间的交互数据是非常容易的,我定义一个全局变量,我在线程A做了更改,线程B可以拿到这个更改,那如果我是三个演员怎么办,我在苹果一做了更改,那这个苹果他是不知道你改了什么的,那这个苹果他也不知道你干了什么,所以说我们如果采用了多进程的话,它们之间,苹果和苹果之间的通讯是会产生障碍的,所以这个就是多进程的一个弊端,那多线程它会比多进程更容易通讯,

那同时呢,也会更高效一些,因为它是一个整体的内容,然后进行的一些资源切换也比较快,那如果是多进程就会比较慢,所以这个就是一个主要的拥有区别。

python多线程

首先,说明一下多线程的应用场景:当python处理多个任务时,这些任务本质是异步的,需要有多个并发事务,各个事务的运行顺序可以是不确定的、随机的、不可预测的。计算密集型的任务可以顺序执行分隔成的多个子任务,也可以用多线程的方式处理。但I/O密集型的任务就不好以单线程方式处理了,如果不用多线程,只能用一个或多个计时器来处理实现。

      下面说一下进程与线程:进程(有时叫重量级进程),是程序的一次执行,正如我们在centos中,ps -aux | grep something 的时候,总有一个他自身产生的进程,也就是这个grep进程,每个进程有自己的地址空间、内存、数据栈、及其他记录其运行轨迹的辅助数据,因此各个进程也不能直接共享信息,只能用进程间通信(IPC)。

      线程(轻量级进程),与进程最大的区别是,所有的线程运行在同一个进程中,共享相同的运行环境,共享同一片数据空间。所以线程之间可以比进程之间更方便的共享数据以及相互通讯,并发执行完成事务。

      为了方便理解记忆进程与线程的关系,我们可以做一个类比:把cpu比作一个搬家公司,而这家搬家公司只有一辆车(进程)来供使用,开始,这家搬家公司很穷,只有一个员工(单线程),那么,这个搬家公司一天,最多只能搬5家,后来,老板赚到钱了,他没买车,而是多雇了n个员工(多线程),这样,每个员工会被安排每次只搬一家,然后就去休息,把车让出来,让其他人搬下一家,这看起来其实并没有提高多少效率,反而增加了成本是吧,这是因为GIL(Global Interpreter Lock) 全局解释器锁,保证了线程安全(保证数据被安全读取),即同时只能有一个线程在CPU上运行,这是python特有的机制,也就是说,即使你的运行环境具有双CPU,python虚拟机也只会使用一个cpu,也就是说GIL 直接导致 CPython 不能利用物理多核的性能加速运算。具体的详细解释(历史遗留问题,硬件发展太快)可以参考这篇博客:

      http://blog.sina.com.cn/s/blog_64ecfc2f0102uzzf.html

      在python核心编程中,作者极力建议我们不要使用thread模块,而是要使用threading模块,原因如下:

   1、当主线程退出时,所有其他线程没有被清除就退出了,thread模块无法保护所有子线程的安全退出。即,thread         模块不支持守护进程。

   2、thread模块的属性有可能会与threading出现冲突。

   3、低级别的thread模块的同步原语很少(实际只有一个,应该是sleep)。

一、thread模块 

 以下是不使用GIL和使用GIL的两个示例代码:

 1.不使用GIL的代码示例:

 1 from time import sleep,ctime
 2 import thread
 3 
 4 def loop0():
 5     print start loop 0 at: ,ctime()
 6     sleep(4)
 7     print loop 0 done at: ,ctime()
 8 def loop1():
 9     print start loop 1 at: ,ctime()
10     sleep(2)
11     print loop 1 done at: ,ctime()
12 def main():
13     print start at: ,ctime()
14     thread.start_new_thread(loop0,())
15     thread.start_new_thread(loop1,())
16     sleep(6)
17     print all loop is done,  ,ctime()
18 
19 if __name__==__main__:
20     main()
21  
22 
23 输出结果:
24 
25 start at:  Thu Jan 28 10:46:27 2016
26 start loop 0 at:   Thu Jan 28 10:46:27 2016
27 
28 start loop 1 at:   Thu Jan 28 10:46:27 2016
29 loop 1 done at:  Thu Jan 28 10:46:29 2016
30 loop 0 done at:  Thu Jan 28 10:46:31 2016
31 all loop is done,  Thu Jan 28 10:46:33 2016

由以上输出可以看出,我们成功开启了两个线程,并且与主线程同步,在第2s时,loop1先完成,第4s时loop0完成,又过了2s,主线程完成结束。整个主线程经过了6s,loop0和loop1同步完成。

 

2、使用GIL的代码示例:

 

 1 import thread
 2 from time import sleep,ctime
 3 loops = [4,2]
 4 def loop(nloop,nsec,lock):
 5     print start loop,nloop,at: ,ctime()
 6     sleep(nsec)
 7     print loop,nloop,done at:,ctime()
 8     lock.release()
 9 def main():
10     print starting at:,ctime()
11     locks = []
12     nloops = range(len(loops))
13 
14     for i in nloops:
15         lock = thread.allocate_lock()                          #创建锁的列表,存在locks中
16         lock.acquire()                         
17         locks.append(lock)                                      
18     for i in nloops:
19         thread.start_new_thread(loop,(i,loops[i],locks[i]))    #创建线程,参数为循环号,睡眠时间,锁
20     for i in nloops:
21         while locks[i].locked():                              #等待循环完成,解锁
22             pass
23     print all DONE at:,ctime()
24 if __name__ == __main__:
25     main()
26  
27 
28 以上输出如下:
29 
30 starting at: Thu Jan 28 14:59:22 2016
31 start loop  0  at:   Thu Jan 28 14:59:22 2016
32 
33 start loop  1  at:   Thu Jan 28 14:59:22 2016
34 loop 1 done at: Thu Jan 28 14:59:24 2016
35 loop 0 done at: Thu Jan 28 14:59:26 2016
36 all DONE at: Thu Jan 28 14:59:26 2016

 

历时4秒,这样效率得到提高,也比在主线程中用一个sleep()函数来计时更为合理。

 

二、threading模块

1、Thread类

在thread类中,可以用以下三种方法来创建线程:

(1)创建一个thread实例,传给它一个函数

(2)创建一个thread实例,传给它一个可调用的类对象

(3)从thread派生出一个子类,创建这个子类的对象

方法(1)

 1 __author__ = dell
 2 import threading
 3 from time import sleep,ctime
 4 def loop0():
 5     print start loop 0 at:,ctime()
 6     sleep(4)
 7     print loop 0 done at:,ctime()
 8 def loop1():
 9     print start loop 1 at:,ctime()
10     sleep(2)
11     print loop 1 done at:,ctime()
12 def main():
13     print starting at:,ctime()
14     threads = []
15     t1 = threading.Thread(target=loop0,args=())          #创建线程
16     threads.append(t1)
17     t2 = threading.Thread(target=loop1,args=())
18     threads.append(t2)
19     for t in threads:
20         t.setDaemon(True)<span style="white-space:pre">    </span>      #开启守护线程(一定要在start()前调用)
21         t.start()<span style="white-space:pre">        </span>      #开始线程执行
22     for t in threads:<span style="white-space:pre">                    </span>
23         t.join()<span style="white-space:pre">        </span>      #将程序挂起阻塞,直到线程结束,如果给出数值,则最多阻塞timeout秒
24 
25 if __name__ == __main__:
26     main()
27     print All DONE at:,ctime()
28 
29 在这里,就不用像thread模块那样要管理那么多锁(分配、获取、释放、检查等)了,同时我也减少了循环的代码,直接自己编号循环了,得到输出如下:
30  
31 
32 starting at: Thu Jan 28 16:38:14 2016
33 start loop 0 at: Thu Jan 28 16:38:14 2016
34 start loop 1 at: Thu Jan 28 16:38:14 2016
35 loop 1 done at: Thu Jan 28 16:38:16 2016
36 loop 0 done at: Thu Jan 28 16:38:18 2016
37 All DONE at: Thu Jan 28 16:38:18 2016

结果相同,但是从代码的逻辑来看,要清晰的多了。其他两种在此就不贴出代码了。实例化一个Thread与调用thread.start_new_thread直接最大的区别就是新的线程不会立即开始执行,也就是说,在threading模块的Thread类中当我们实例化之后,再调用.start()函数后被统一执行,这使得我们的程序具有很好的同步特性。

下面是单线程与多线程的一个对比示例,分别以乘除完成两组运算,从而看出多线程对效率的提高

 1 from time import ctime,sleep
 2 import threading
 3 
 4 def multi():
 5     num1 = 1
 6     print start mutiple at:,ctime()
 7     for i in range(1,10):
 8        num1 = i*num1
 9        sleep(0.2)
10     print mutiple finished at:,ctime()
11     return num1
12 def divide():
13     num2 = 100
14     print start division at:,ctime()
15     for i in range(1,10):
16         num2 = num2/i
17         sleep(0.4)
18     print division finished at:,ctime()
19     return num2
20 def main():
21     print ---->single Thread
22     x1 = multi()
23     x2 = divide()
24     print The sum is ,sum([x1,x2]),\nfinished singe thread,ctime()
25 
26     print ----->Multi Thread
27     threads = []
28     t1 = threading.Thread(target=multi,args=())
29     threads.append(t1)
30     t2 = threading.Thread(target=divide,args=())
31     threads.append(t2)
32     for t in threads:
33         t.setDaemon(True)
34         t.start()
35     for t in threads:
36         t.join()
37 
38 if __name__ == __main__:
39     main()
40 
41 结果如下:
42 
43  
44 
45 ---->single Thread
46 
47 start mutiple at: Thu Jan 28 21:41:18 2016
48 
49 mutiple finished at: Thu Jan 28 21:41:20 2016
50 
51 start division at: Thu Jan 28 21:41:20 2016
52 
53 division finished at: Thu Jan 28 21:41:24 2016
54 
55 The sum is  362880 
56 
57 finished singe thread Thu Jan 28 21:41:24 2016
58 
59 ----->Multi Thread
60 
61 start mutiple at: Thu Jan 28 21:41:24 2016
62 
63 start division at: Thu Jan 28 21:41:24 2016
64 
65 mutiple finished at: Thu Jan 28 21:41:26 2016
66 
67 division finished at: Thu Jan 28 21:41:27 2016
68 
69 The sum is : 362880

 

以上是关于python多线程处理的主要内容,如果未能解决你的问题,请参考以下文章

Python 多进程多线程效率比较

Python 多线程效率不高吗

如何多线程(多进程)加速while循环(语言-python)?

Java多线程程序设计初步入门

python中的线程安全和非线程安全的区别

python 多进程/多线程/协程 同步异步