【Python】多线程

1. 为什么需要多线程呢?

为什么需要多线程呢?总结一下,多线程多应用在如下场景:

  • 需要运行后台任务但不希望停止主线程的执行
    • 定期打印日志
    • 图形界面下,主循环需要等待事件
  • 分散任务负载
    • 高负载任务一般分计算密集型、IO密集型两类。

2. 计算密集型 vs. IO密集型

计算密集型任务的特点是要进行大量的计算,消耗CPU资源,比如计算圆周率、对视频进行高清解码等等,全靠CPU的运算能力。这种计算密集型任务虽然也可以用多任务完成,但是任务越多,花在任务切换的时间就越多,CPU执行任务的效率就越低,所以,要最高效地利用CPU,计算密集型任务同时进行的数量应当等于CPU的核心数。计算密集型任务由于主要消耗CPU资源,因此,代码运行效率至关重要。

IO密集型,涉及到网络、磁盘IO的任务都是IO密集型任务,这类任务的特点是CPU消耗很少,任务的大部分时间都在等待IO操作完成(因为IO的速度远远低于CPU和内存的速度)。对于IO密集型任务,任务越多,CPU效率越高,但也有一个限度。常见的大部分任务都是IO密集型任务,比如Web应用。IO密集型任务执行期间,99%的时间都花在IO上,花在CPU上的时间很少。

计算密集型验证例子

single_thread.py   python单线程耗时11s

import time
from threading import Thread
# Python的多线程困境
# 在计算密集的时候python的多线程切换开销的影响
# my_counter()就是一个纯CPU计算代码段,不会被阻塞。当线程运行my_counter()的时候只有
# 在线程结束或者线程轮转时间片到达之后才会释放GIL锁,进行线程切换。

"""
由于主线程一直在阻塞等待,所以我们不考虑主线程的切换的情况下,顺序执行的过程中,线程切换只发生一次,就是第一个线程运行结束,
然后切换到第二个线程进行运行,总共的运行时间为11.6s,
"""
def my_counter():
    i = 0
    for _ in range(100000000):
        i = i + 1
    return True

# 顺序执行的单线程
def main():
    thread_array = {}
    start_time = time.time()
    for tid in range(2):
        t = Thread(target=my_counter)
        t.start()
        t.join()
    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))


if __name__ == '__main__':
    main()

"""
这个运行时间也不是固定的,有时候也会快于多线程的代码
D:\appp\Anaconda3\envs\py37\python.exe F:/xxx/pyEfficient/single_thread.py
Total time: 11.616111040115356
"""

multi_thread.py    python多线程耗时12s 

from threading import Thread
import time
"""
在第二个程序中,我们同时创建两个子线程,“同时运行”my_counter(),
python程序进程运行过程中,两个子线程会频繁的切换直到结束,操作系统就得不停的保存上下文,切换上下文,带来了很多额外的开销,
两个子线程“同时运行”程序,时间非但没有缩短,反而长了近一倍,这就是python线程切换带来的开销。
"""

def my_counter():
    i = 0
    for _ in range(100000000):
        i = i + 1
    return True


def main():
    thread_array = {}
    start_time = time.time()
    for tid in range(2):
        # 开启多线程
        t = Thread(target=my_counter)
        # 启动线程活动
        t.start()
        thread_array[tid] = t
    for i in range(2):
        # join()等待这个线程结束
        thread_array[i].join()
    end_time = time.time()
    print("Total time: {}".format(end_time - start_time))
    print(thread_array)


if __name__ == '__main__':
    main()


"""
F:/xxx/pyEfficient/multi_thread.py
Total time: 12.08783769607544
{0: , 1: }
"""

总结:正如《Python高手之路》所言: (Python)处理好多线程是很难的,其复杂程度意味着与其他方式(异步事件\多进程)相比它是bug的更大来源,而且考虑到通常能够获取的好处很少,所以最好不要在多线程上浪费太多精力。

通常用协程来解决python并发的任务

3. 但是实际场景下,运行多线程确实速度加快了,怎么解释?

比如一个拥有2个线程的python进程运行在2核的CPU上,我们假设每个线程都只涉及到纯CPU计算,不会被阻塞,只有线程运行的时间片到达才会进行线程切换,每个线程任务完成需要运行4s。我们编号2个线程为T1,T2,编号2个核为C1,C2.如果是两个个非python线程,是可以上做到上图所示的C1调度执行T1,C2调度执行T2, 2个线程并行执行,那么上述进程执行结束共需要4s。

但是由于CPython中GIL锁的存在,C1调度执行T1的时候,GIL锁被T1占着,T2拿不到GIL锁,处于阻塞的状态,等到T1执行结束或者执行的字节码行数到了设定的阈值,T1就会释放GIL锁,然后T2获得GIL锁之后再继续执行。这样的结果就是,这个拥有2个纯CPU计算线程的python程序进程运行结束需要8s,因为每个时刻,python进程中永远只有一个线程再被运行。那这就很胃疼了,这么看似乎python的多线程就没用了?也不是的, 上述情况下多线程没用,是因为我们假定的是每个线程运行代码都是纯CPU计算过程,不会遇到IO等阻塞操作,只在执行结束或者“轮转时间片”到了之后才会被切换,( 之所以打引号,是因为python的多线程调度的轮转时间片并不是常规CPU时间片,而是按照字节码来算的)。但是如果T1线程有IO操作会被阻塞,会在IO操作前提前释放GIL锁,进而T2线程获得GIL,可以正常被CPU调度执行,这样Python程序进程仍然处于继续运行的状态,而不会像单线程的时候遇到IO会被阻塞等待。话虽如此,除了少部分高端玩家,大部分情况下,我们用python的多线程时,不但没有发挥出多线程的并行威力,反而还承受了多线程的高昂的切换开销以及应对复杂的锁同步的问题。

4. 模拟实战

import time
import threading
from time import ctime,sleep


def music(func):
    for i in range(2):
        print("I was listening to %s. %s" %(func,ctime()))
        sleep(4)

def move(func):
    for i in range(2):
        print("I was at the %s! %s" %(func,ctime()))
        sleep(5)

# 创建了threads数组
threads = []
# 创建线程t1,使用threading.Thread()方法,在这个方法中调用music方法target=music,args方法对music进行传参。
t1 = threading.Thread(target=music,args=(u"好日子",))
# 把创建好的线程t1装到threads数组中
threads.append(t1)
t2 = threading.Thread(target=move,args=(u"上海",)) # 注意这里的,不要丢
threads.append(t2)

if __name__ == '__main__':
    # 最后通过for循环遍历数组。(数组被装载了t1和t2两个线程)
    for t in threads:
        # setDaemon(True)将线程声明为守护线程,必须在start() 方法调用之前设置,
        # 如果不设置为守护线程程序会被无限挂起。子线程启动后,父线程也继续执行下去,
        # 当父线程执行完最后一条语句print "all over %s" %ctime()后,没有等待子线程,直接就退出了,同时子线程也一同结束。
        t.setDaemon(True)
        # 开始线程活动
        t.start()
    # 子线程(muisc 、move )和主线程(print "all over %s" %ctime())都是同一时间启动,
    # 但由于主线程执行完结束,所以导致子线程也终止。
    # 加了个join()方法,用于等待线程终止。join()的作用是,在子线程完成运行之前,这个子线程的父线程将一直被阻塞。
    t.join()
    # 注意:  join()方法的位置是在for循环外的,也就是说必须等待for循环里的两个进程都结束后,才去执行主进程。
    print("all over %s" % ctime())


    """
    # 子线程启动36分02秒,主线程运行36分12秒。
    I was listening to 好日子. Tue Aug 18 17:36:02 2020   # 开启多线程,两个方法同时运行
    I was at the 上海! Tue Aug 18 17:36:02 2020
    I was listening to 好日子. Tue Aug 18 17:36:04 2020    # sleep(2)
    I was at the 上海! Tue Aug 18 17:36:07 2020       # sleep(5)
    all over Tue Aug 18 17:36:12 2020
    """


    """
    # 子线程启动40分08秒,主线程运行40分18秒 。
    # 可以看出是按照时间多的那个线程计算
    I was listening to 好日子. Tue Aug 18 17:40:08 2020
    I was at the 上海! Tue Aug 18 17:40:08 2020
    I was listening to 好日子. Tue Aug 18 17:40:12 2020  # sleep(4)
    I was at the 上海! Tue Aug 18 17:40:13 2020  # sleep(5)
    all over Tue Aug 18 17:40:18 2020
    """

注意:这里这样写是有问题的!

if __name__ == '__main__':
    for t in threads:
        t.setDaemon(True)
        t.start()
    print("all over %s" % ctime())

这个地方写的有问题,如果前面的t1进程执行速度较慢,后面的t2进程执行速度快,但是只有最后一个t2进程设置了t.join(),会导致前面的t1进程被主线程终止。把music中sleep(7)休眠时间改为大于5 问题就暴露出来了,当t2线程结束之后,程序结束,但是t1进程还没有执行完毕,这明显与我们的初衷不符。

 正确写法

if __name__ == '__main__':
    for t in threads:
        t.setDaemon(True)
        t.start()
 
    for t in threads:
        t.join()
 
    print "all over %s" %ctime()

"""
子线程启动49分00秒,主线程运行49分14秒
I was listening to 好日子. Tue Aug 18 17:49:00 2020  # sleep(7)
I was at the 上海! Tue Aug 18 17:49:00 2020       # sleep(5)
I was at the 上海! Tue Aug 18 17:49:05 2020
I was listening to 好日子. Tue Aug 18 17:49:07 2020  
all over Tue Aug 18 17:49:14 2020
"""


原来错误的运行结果,可以发现t1没有运行完。
子线程启动50分57秒,主线程运行51分07秒。这里只运行了10s程序结果,而t1循环两边要14s,故漏掉了
I was listening to 好日子. Tue Aug 18 17:50:57 2020
I was at the 上海! Tue Aug 18 17:50:57 2020
I was at the 上海! Tue Aug 18 17:51:02 2020
I was listening to 好日子. Tue Aug 18 17:51:04 2020
all over Tue Aug 18 17:51:07 2020

【Python】多线程_第1张图片

模拟下上面程序的单线程,emmmm确实速度有加快。 

from time import ctime,sleep

def music(func):
    for i in range(2):
        print("I was listening to %s. %s" %(func,ctime()))
        sleep(7)

def move(func):
    for i in range(2):
        print("I was at the %s! %s" %(func,ctime()))
        sleep(5)

if __name__ == '__main__':
    music(u"好日子")
    move(u"上海")
    print("all over %s" % ctime())

"""
music开始运行56分40秒,等待运行2次
move开始运行56分54秒,+14s
程序总共运行结束时间57分04秒,总耗时7*2+5*2=24s
"""

 为什么加快了呢?目前还不知道,未完待续


参考:

Python线程、协程探究(1)——Python的多线程困境 - 大龙的文章 - 知乎

Thread之三:Thread Join()的用法

谈谈Python多线程

python 多线程就这么简单(实战代码来自这里)

你可能感兴趣的:(编程语言)