Python学习笔记(16), 多线程 & 分布式进程

文章目录

  • 多线程
    • Lock
    • 多核CPU
    • ThreadLocal
    • 进程 vs 线程
    • 分布式进程

多线程

Python中的线程是真正的POSIX Thread,而不是模拟出来的线程。这一点与Java不同,Java中的线程是运行在JVM上的线程。

注意, Python由于设计时有GIL全局锁,导致了过线程无法利用多核。多线程的并发在Python中就是一个美丽的梦。

尽管如此,还是来学习一下Python中的多线程,用threading这个高级模块。启动一个线程就把一个函数传入并创建Thread实例,然后调用start()开始执行。

import time, threading

# 新线程执行的代码:
def loop():
    print('thread % s is running...' % threading.current_thread().name)
    n = 0
    while n < 5:
        n = n + 1
        print('thread %s >>> %s' % (threading.current_thread().name, n))
        time.sleep(1)
    print('thread %s ended' % threading.current_thread().name)

print('thread %s is running...' % threading.current_thread().name)
t = threading.Thread(target=loop, name='LoopThread')
t.start()
t.join()
print('thread %s ended' % threading.current_thread().name)
thread MainThread is running...
thread LoopThread is running...
thread LoopThread >>> 1
thread LoopThread >>> 2
thread LoopThread >>> 3
thread LoopThread >>> 4
thread LoopThread >>> 5
thread LoopThread ended
thread MainThread ended

任何进程默认会启动一个线程,这个线程名字就叫MainThread。 在创建Thread时,也可以指定线程名称,这里指定为LoopThread.

Lock

每个进程都有自己独立的地址空间,但是线程不一样,一个进程可以有多个线程,进程内的数据可以被线程共享。这样就存在同步的问题,解决同步问题的常用方法就是加锁。

import time, threading

balance = 0

def change_it(n):
    global balance   # balance是全局变量,回忆变量作用域部分
    balance = balance + n
    balance = balance - n

def run_thread(n):
    for i in range(10000000):
        change_it(n)

t1 = threading.Thread(target=run_thread, args=(5,))
t2 = threading.Thread(target=run_thread, args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print(balance)
85

如上,如果输出的结果不为零,就说明出了问题。Python中用threading.Lock()来加锁。

import time, threading

balance = 0
lock = threading.Lock()  # 创建一个锁

def change_it(n):
    global balance   # balance是全局变量,回忆变量作用域部分
    balance = balance + n
    balance = balance - n

def run_thread(n):
    for i in range(10000000):
        lock.acquire()  # 获取锁
        try: 
            change_it(n)
        finally:
            lock.release()  # 释放锁

t1 = threading.Thread(target=run_thread, args=(5,))
t2 = threading.Thread(target=run_thread, args=(8,))
t1.start()
t2.start()
t1.join()
t2.join()
print(balance)
0

锁的好处是可以保证临界区的互斥,但是阻止了多线程的并发执行。而且如果系统中存在多个锁,可能会造成死锁。

关于这方面的讨论,可以参考操作系统的经典教材《现代操作系统》或者《操作系统原理》

多核CPU

为了验证Python中的一个进程只能运行一个线程的问题,也就是GIL锁的历史遗留问题。

import threading, multiprocessing

def loop():
    x = 0
    while True:
        x = x ^ 1
    
for i in range(multiprocessing.cpu_count()):
    t = threading.Thread(target=loop)
    t.start()

启动与CPU核心数量相同的N个线程,在4核CPU上可以监控到CPU占用率仅有102%,也就是仅使用了一核。

但是用C、C++或Java来改写相同的死循环,直接可以把全部核心跑满,4核就跑到400%,8核就跑到800%,为什么Python不行呢?

因为Python的线程虽然是真正的线程,但解释器执行代码时,有一个GIL锁:Global Interpreter Lock,任何Python线程执行前,必须先获得GIL锁,然后,每执行100条字节码,解释器就自动释放GIL锁,让别的线程有机会执行。这个GIL全局锁实际上把所有线程的执行代码都给上了锁,所以,多线程在Python中只能交替执行,即使100个线程跑在100核CPU上,也只能用到1个核。

GIL是Python解释器设计的历史遗留问题,通常我们用的解释器是官方实现的CPython,要真正利用多核,除非重写一个不带GIL的解释器。

Python虽然不能利用多线程实现多核任务,但可以通过多进程实现多核任务。多个Python进程有各自独立的GIL锁,互不影响。

ThreadLocal

想要创建只有线程自己能看见的局部变量,需要用到ThreadLocal

局部变量的好处是不会影响其他线程,缺点在于函数调用的时候,传递起来很麻烦:

def process_student(name):
    std = Student(name)
    # std是局部变量,但是每个函数都要用它,因此必须传进去:
    do_task_1(std)
    do_task_2(std)

def do_task_1(std):
    do_subtask_1(std)
    do_subtask_2(std)

def do_task_2(std):
    do_subtask_2(std)
    do_subtask_2(std)

每一层调用都像这样传递参数非常麻烦,但是用全局变量也不行,因为每个线程处理不同的Student对象。

可以考虑用全局的dict来存放所有的Student对象,线程自身作为key来从dict中获取对象

global_dict = {}

def std_thread(name):
    std = Student(name)
    # 把std放到全局变量global_dict中:
    global_dict[threading.current_thread()] = std
    do_task_1()
    do_task_2()

def do_task_1():
    # 不传入std,而是根据当前线程查找:
    std = global_dict[threading.current_thread()]
    ...

def do_task_2():
    # 任何函数都可以查找出当前线程的std变量:
    std = global_dict[threading.current_thread()]

理论上可行,但是代码很丑。好在ThreadLocal能帮我们自动做这件事

import threading
    
# 创建全局ThreadLocal对象:
local_school = threading.local()

def process_student():
    # 获取当前线程关联的student:
    std = local_school.student
    print('Hello, %s (in %s)' % (std, threading.current_thread().name))

def process_thread(name):
    # 绑定ThreadLocal的student:
    local_school.student = name
    process_student()

t1 = threading.Thread(target= process_thread, args=('Alice',), name='Thread-A')
t2 = threading.Thread(target= process_thread, args=('Bob',), name='Thread-B')
t1.start()
t2.start()
t1.join()
t2.join()
Hello, Alice (in Thread-A)
Hello, Bob (in Thread-B)

可以理解为全局变量local_school是一个dict,不但可以用local_school.student,还可以绑定其他变量,如local_school.teacher等等。

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

进程 vs 线程

  • 进程稳定,但是开销大;线程上下文切换开销小,但是一但一个线程崩溃,整个进程都崩溃
  • 计算密集型的任务,最好用C语言,需要提高计算效率,I/O密集型任务,99%的时间都花在I/O上,因此用速度极快的C语言并不能提高效率,最适合的语言就是开发效率最高的语言,脚本语言是首选。
  • 异步I/O, 现代操作系统对IO操作已经做了巨大的改进,最大的特点就是支持异步IO。如果充分利用操作系统提供的异步IO支持,就可以用单进程单线程模型来执行多任务,这种全新的模型称为事件驱动模型Nginx就是支持异步I/O的Web服务器,它在单核CPU上采用单进程模型就可以高效地支持多任务。
  • 对应到Python语言,单线程的异步编程模型称为协程,有了协程的支持,就可以基于事件驱动编写高效的多任务程序。

分布式进程

Python的multiprocessing模块不但支持多进程,其中managers子模块还支持把多进程分布到多台机器上。一个服务进程可以作为调度者把任务分布到其他多个进程中,依靠网络通信。

已经有一个通过Queue通信的多进程程序,通过manager模块把Queue通过网络暴露出去,就可以让其他机器的进程访问Queue了。

在一个terminal窗口中运行python task_master.py,另一个窗口运行python task_worker.py

# task_master.py

import random, time, queue
from multiprocessing.managers import BaseManager

# 发送任务的队列
task_queue = queue.Queue()
# 接收结果的队列
result_queue = queue.Queue()

# 从BaseManager继承的QueueManager:
class QueueManager(BaseManager):
    pass

# 把两个Queue都注册到网络上,callable参数关联了Queue对象:
QueueManager.register('get_task_queue', callable=lambda: task_queue)
QueueManager.register('get_result_queue', callable=lambda: result_queue)
# 绑定端口5000, 设置验证码'abc':
manager = QueueManager(address=('', 5000), authkey=b'abc')
# 启动Queue:
manager.start()
# 获得通过网络访问的Queue对象:
task = manager.get_task_queue()
result = manager.get_result_queue()

# 放几个任务进去:
for i in range(10):
    n = random.randint(0, 10000)
    print('Put task %d...' % n)
    task.put(n)
# 从result队列读取结果:
print('Try get results...')

for i in range(10):
    r = result.get(timeout=10)
    print('Result: %s' % r)
# 关闭:
manager.shutdown()
print('master exit.')

Output of task_master.py

Put task 7375...
Put task 3520...
Put task 1164...
Put task 4993...
Put task 1985...
Put task 5135...
Put task 2874...
Put task 3782...
Put task 7440...
Put task 3252...
Try get results...
Result: 7375 * 7375 = 54390625
Result: 3520 * 3520 = 12390400
Result: 1164 * 1164 = 1354896
Result: 4993 * 4993 = 24930049
Result: 1985 * 1985 = 3940225
Result: 5135 * 5135 = 26368225
Result: 2874 * 2874 = 8259876
Result: 3782 * 3782 = 14303524
Result: 7440 * 7440 = 55353600
Result: 3252 * 3252 = 10575504
master exit.

Output of task_worker.py

Connect to server 127.0.0.1...
run task 7375 * 7375...
run task 3520 * 3520...
run task 1164 * 1164...
run task 4993 * 4993...
run task 1985 * 1985...
run task 5135 * 5135...
run task 2874 * 2874...
run task 3782 * 3782...
run task 7440 * 7440...
run task 3252 * 3252...
worker exit.

这个简单的Master/Worker模型有什么用?其实这就是一个简单但真正的分布式计算,把代码稍加改造,启动多个worker,就可以把任务分布到几台甚至几十台机器上,比如把计算n*n的代码换成发送邮件,就实现了邮件队列的异步发送。

Queue对象存储在哪?注意到task_worker.py中根本没有创建Queue的代码,所以,Queue对象存储在task_master.py进程中:

而Queue之所以能通过网络访问,就是通过QueueManager实现的。由于QueueManager管理的不止一个Queue,所以,要给每个Queue的网络调用接口起个名字,比如get_task_queue。

authkey有什么用?这是为了保证两台机器正常通信,不被其他机器恶意干扰。如果task_worker.py的authkey和task_master.py的authkey不一致,肯定连接不上。

小结:

Python的分布式进程接口简单,封装良好,适合需要把繁重任务分布到多台机器的环境下。

注意Queue的作用是用来传递任务和接收结果,每个任务的描述数据量要尽量小。比如发送一个处理日志文件的任务,就不要发送几百兆的日志文件本身,而是发送日志文件存放的完整路径,由Worker进程再去共享的磁盘上读取文件。

你可能感兴趣的:(Python)