目录

Python 多线程笔记

中国有句古话:好记性不如烂笔头。看会了和学会了是两码事,能教会别人说明是真掌握了。

比起一堆 job1 | job2生产者-消费者 模型,单纯复制粘贴相似度 99% 的代码就算自己敲一遍也不会有印象。

反向思考

请写出一个死锁的案例

加锁即为互斥,防止多线程同时修改同一个资源导致数据错误。

A 拿酱油
    -> 酱油炒饭耗时10分钟
        -> 拿盐  # 等待B放回盐
            -> 盐水菜心耗时5分钟
                -> 放回盐
                    -> 放回酱油

B 拿盐
    -> 盐水菜心耗时5分钟
        -> 拿酱油  # 等待A放回酱油
            -> 酱油炒饭耗时10分钟
                -> 放回酱油
                    -> 放回盐

你在等酱油我在盐,两人的下一道菜就永远卡这了。

当然这种写法肯定是有问题的,资源执行和释放的顺序不一致。

解决方法

让步:

只要每个线程执行完任务立马释放相应资源,线程崩溃的话设置好超时释放。

排队:

同时竞争资源,用队列(推荐)解决。

规则:

占用资源前给予规则限制。
例子:5人吃饭同时拿起左边筷子并等待右边筷子的释放。
解决:给筷子编号,没拿到小号筷子前不能拿大号的。最大数字的筷子不可能单独拿到,问题解决。

如果用多进程处理多线程代码,会发生什么

问的其实是二者在使用方面的区别,首先自然是确定多线程代码执行的任务是什么。

如果要处理 `IO密集型`任务,用多进程反而会慢,主要耗时在创建和维护进程过程。
如果处理 `CPU密集型`任务,则可以充分利用多核优势并行计算。

多进程的主要问题:

  • 进程不共享内存,计算的输入必须被传到每个工作进程里,比如列表中的元素;
  • 能被传递的东西必须 picklable,而有相当多的东西是 unpicklable 的;
  • 如果后续程序执行需要并行计算的输出,那么这些输出也得 picklable;
  • Pickle -> unpickle 操作带来了额外的性能开销。

多线程涉及资源竞争&修改死锁上下文切换导致效率下降问题。

什么时候加 .join()

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

def fun(second):
    time.sleep(second)
    print(f"{threading.current_thread().name} -> execute fun")

t1 = threading.Thread(target=fun, args=(5, ))
t2 = threading.Thread(target=fun, args=(3, ))
t1.start()
t2.start()
t1.join()
t2.join()
# 甚至这样
t1.start()
t1.join()
t2.start()
t2.join()

很多人会这么写多线程代码,执行后会发现上面写法需要 5 秒多点没问题,但是下面的写法串行输出一共需要 5+3 秒,然后:

这是 GIL 导致的多线程效率问题,所以我们用多进程吧。

呃呃。

.join() 会卡住主线程,并让当前已经 .start() 的子线程继续运行,直到调用 .join() 的这个线程运行完毕才会继续运行主线程。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
if __name__ == '__main__':
    print("这里是主线程")
    t1 = Thread(target=task1)
    # t1.setDaemon(True)  # 设置为守护进程,必须在start之前
    t1.start()
    # 阻塞
    t1.join()
    print("主线程结束了")

>>> "这里是主线程"
    "线程1执行完毕"
    "主线程结束了"

就算给每个线程都加,耗时短的任务立刻返回结果让其他子线程继续执行,没有任何意义。

.join() 强调顺序执行,你只需要阻塞耗时最长的线程任务即可。

所以上述代码改成这样:

1
2
3
t1.start()
t2.start()
t1.join()

为什么爬虫多个 url 返回前,每个线程都要加 .join()呢?因为每个线程在执行网络请求的时候,返回时间有差异,必须保证每个线程返回结果主线程才能调度后续任务。

1
2
3
4
5
6
7
thread_list = []
for _ in range(10):
    thread = threading.Thread(target=scrapy, args=(urls))
    thread_list.append(thread)

for thread in thread_list:
    thread.join()

什么时候加 .setDeamon(True)

守护:与主线程同生共死,这么理解就很直观了。

如果主线程永远不退出,设置守护线程无意义。

如果把子线程的代码写在 while True 里面一直循环,要设置为守护线程。不然主线程结束了,子线程还一直运行,程序结束不了。

事件的使用场景

常规使用场景:

用户外部发送终止命令结束程序,彻底结束子线程。
等待数据库的连接信号就绪。

池与信号量是两个完全不同的概念 ,进程池Pool(4),最大只能产生4个进程,而且从头到尾都只是这四个进程,不会产生新的,而信号量是产生一堆线程/进程。