代码环境:python3.6

上一篇文章我们介绍了 python 中多进程的使用:点击阅读,现在我们讲讲多线程。


进程由若干个线程组成,一个进程至少有一个线程。任何进程启动的时候,都会默认启动一个线程,我们称之为主线程,再由主线程去创建其他新的子线程。

简单多线程示例

常用的多线程模块是threading,示例:

from threading import current_thread, Thread, Lock

num = 0


def worker1(lock):
    global num
    thread_name = current_thread().name
    print(f'子线程 {thread_name} 运行...')

    for i in range(100000):
        with lock:
            num += 1

    print(f'子线程 {thread_name} 结束。')


def worker2(lock):
    global num
    thread_name = current_thread().name
    print(f'子线程 {thread_name} 运行...')

    for i in range(100000):
        with lock:
            num += 1

    print(f'子线程 {thread_name} 结束。')


def thread_main():
    lock = Lock()

    print(f'当前主线程:{current_thread().name}')
    t1 = Thread(target=worker1, args=(lock, ))
    t2 = Thread(target=worker2, args=(lock, ))
    t1.start()
    t2.start()

    t1.join()
    t2.join()

    print(f'最终num的值:{num}')
    print(f'主线程 {current_thread().name} 结束。')


if __name__ == '__main__':
    thread_main()

上面的例子中,我们最终的期望是num=200000,假如在操作数据的时候去掉with lock:,多运行几次代码,可能没法得到我们的期望值。这是因为:同一个进程的多线程,共享除栈和寄存器外的所有数据,多线程操作同一个全局变量引起数据冲突。

为了避免这种冲突,我们在操作全局变量的时候需要使用threading.Lock给数据上锁。

另外,如果我们去掉join()方法,会发现主线程可能提前结束,但子线程依然可以继续执行,这点跟多进程是有区别的:主线程终止不会造成子线程终止。

ThreadLocal

在多线程环境下,每个线程都有自己的数据。一个线程使用自己的局部变量比使用全局变量好,因为局部变量只有线程自己能看见,不会影响其他线程,而全局变量的修改必须加锁。

但局部变量在函数调用的时候传递起来很麻烦,所以我们用threading.local()创建的ThreadLocal对象来代替。

示例:

from threading import current_thread, Thread, local

# 创建全局的ThreadLocal
thread_local = local()


def call_student():
    # 可以直接访问到当前线程关联的student
    print(f'{current_thread().name}:我是学生{thread_local.student}')


def student_worker(name):
    # 给执行这个方法的线程绑定一个属性student,这个线程的方法都能访问到
    thread_local.student = name

    call_student()


def thread_main():
    t1 = Thread(target=student_worker, args=('Amy', ))
    t2 = Thread(target=student_worker, args=('Tom', ))
    t1.start()
    t2.start()
    t1.join()
    t2.join()


if __name__ == '__main__':
    thread_main()

可以理解为thread_local就是一个全局的dict,每个线程都可以绑定各自的属性,如thread_local.studentthread_local.teacher等等,不同线程可以任意读写互不干扰,并且ThreadLocal内部已经自动处理了锁的问题。

ThreadLocal最常用的地方就是为每个线程绑定一个数据库连接、HTTP请求、用户身份信息等,这样一个线程的所有调用到的处理函数都可以非常方便地访问这些资源。

线程间通信:生产者-消费者模型

线程间通信常用queue.Queue

在多线程开发中,假设一个线程负责生产,一个线程负责消费,如果生产速度快过消费速度,那么生产者就需要等消费者处理完才能继续生产数据。为了解决这种处理能力不平衡的问题,我们引入了生产者-消费者模型,在两者之间建立一个缓冲区Queue进行通信。

示例:

from threading import Thread, current_thread, local
from queue import Queue
import time, random

# 缓冲区,存储产品队列
store_queue = Queue(5)


def producer():
    thread_name = current_thread().name

    # 生产产品的编号
    # 局部变量不需要锁
    sn = 0

    while sn < 10:
        if not store_queue.full():
            msg = thread_name + ':编号' + str(sn)
            store_queue.put(msg)
            print(f'{thread_name}线程生产了{msg}')
            sn += 1


def consumer():
    thread_name = current_thread().name

    # 统计消费的产品数量,方便后面终止循环
    consume_num = 0

    while consume_num < 20:
        if not store_queue.empty():
            msg = store_queue.get()
            print(f'{thread_name}线程消费了{msg}')
            consume_num += 1
            time.sleep(random.random())


def thread_main():

    # 2个生产者线程
    for i in range(2):
        t_p = Thread(target=producer, name=f'Producer-{i}')
        t_p.start()

    # 1个消费者线程
    t_c = Thread(target=consumer, name='Consumer')
    t_c.start()


if __name__ == '__main__':
    thread_main()

全局解释器锁GIL

python 解释器执行代码时,有一个 GIL:任何 python 线程执行前,必须先获得 GIL,然后,每执行100条字节码就自动释放 GIL,让别的线程有机会执行。

GIL 的存在,让多线程只能交替执行,即使一台机器的 CPU 有 100 个核心,一个进程内的 100 个线程也只能用到 1 核。

所以,如果要真正让多线程跑满核数,一个方法是利用 C 语言编写的 python 库管理 GIL,但这样会极大增加代码复杂度,我们一般不这么做。那多线程就没用了吗?并不是!

python 标准库中所有执行阻塞型 IO 操作的函数,在等待操作系统返回结果时都会释放 GIL,time.sleep()函数也会释放。所以,多线程能在 IO 密集型操作中发挥作用。

总结

  1. 我们常用threading模块使用多线程,同一个进程内的多线程数据共享;
  2. 多线程内,更推荐使用变量的方式:ThreadLocal对象和局部变量,操作同一个全局变量容易引起数据冲突,需要加锁隔离;
  3. 线程间通信使用queue.Queue
  4. GIL 的存在导致多线程无法利用多核,但多线程可以在 IO 密集型操作中发挥作用,如果必须要利用多核,可以使用多进程。

oldk
3 声望2 粉丝