python的多进程与多线程知识点提炼

结合廖雪峰大神的教程进行了一些知识点的提炼。

廖神的教程地址为:原文地址

1. 进程

Unix/Linux操作系统提供了一个fork()系统调用,它非常特殊。普通的函数调用,调用一次,返回一次,但是fork()调用一次,返回两次,因为操作系统自动把当前进程(称为父进程)复制了一份(称为子进程),然后,分别在父进程和子进程内返回。

子进程永远返回0,而父进程返回子进程的ID。这样做的理由是,一个父进程可以fork出很多子进程,所以,父进程要记下每个子进程的ID,而子进程只需要调用getppid()就可以拿到父进程的ID。

Python的os模块封装了常见的系统调用,其中就包括fork,可以在Python程序中轻松创建子进程:

import os

print('Process (%s) start...' % os.getpid())
# Only works on Unix/Linux/Mac:
pid = os.fork()
if pid == 0:
    print('I am child process (%s) and my parent is %s.' % (os.getpid(), os.getppid()))
else:
    print('I (%s) just created a child process (%s).' % (os.getpid(), pid))

由于Windows没有fork调用,上面的代码在Windows上无法运行。

有了fork调用,一个进程在接到新任务时就可以复制出一个子进程来处理新任务,常见的Apache服务器就是由父进程监听端口,每当有新的http请求时,就fork出子进程来处理新的http请求。 

multiprocessing模块是Python跨平台版本的多进程模块。multiprocessing模块提供了一个Process类来代表一个进程对象,下面的例子演示了启动一个子进程并等待其结束:

from multiprocessing import Process
import os

# 子进程要执行的代码
def run_proc(name):
    print('Run child process %s (%s)...' % (name, os.getpid()))

if __name__=='__main__':
    print('Parent process %s.' % os.getpid())
    p = Process(target=run_proc, args=('test',))
    print('Child process will start.')
    p.start()
    p.join()
    print('Child process end.')

注意:这里一定要加上 if __name__=='__main__',否则程序执行时会产生错误。

创建子进程时,只需要传入一个执行函数和函数的参数,创建一个Process实例,用start()方法启动,join()方法可以等待子进程结束后再继续往下运行,通常用于进程间的同步。

如果要启动大量的子进程,可以用进程池Pool批量创建子进程:

from multiprocessing import Pool
import os, time, random

def long_time_task(name):
    print('Run task %s (%s)...' % (name, os.getpid()))
    start = time.time()
    time.sleep(random.random() * 3)
    end = time.time()
    print('Task %s runs %0.2f seconds.' % (name, (end - start)))

if __name__=='__main__':
    print('Parent process %s.' % os.getpid())
    p = Pool(4)
    for i in range(5):
        p.apply_async(long_time_task, args=(i,))
    print('Waiting for all subprocesses done...')
    p.close()
    p.join()
    print('All subprocesses done.')

程序的运行结果为:

Parent process 669.
Waiting for all subprocesses done...
Run task 0 (671)...
Run task 1 (672)...
Run task 2 (673)...
Run task 3 (674)...
Task 2 runs 0.14 seconds.
Run task 4 (673)...
Task 1 runs 0.27 seconds.
Task 3 runs 0.86 seconds.
Task 0 runs 1.41 seconds.
Task 4 runs 1.91 seconds.
All subprocesses done.

 注对Pool对象调用join()方法会等待所有子进程执行完毕。

注意:调用join()之前必须先调用close(),调用close()之后就不能继续添加新的Process了。

输出的结果task 0123是立刻执行的,而task 4要等待前面某个task完成后才执行,这是因为Pool的大小是4,因此最多同时执行4个进程。Pool的默认大小是CPU的核数。

subprocess模块可以让我们非常方便地启动一个子进程,通过subprocess可以在python启动外部命令,之前在python识别验证码这篇文章中就用subprocess启动tesseract识别图片的内容,现在再看看其它的例子:

import subprocess

print('nslookup www.python.org')
r = subprocess.call(['nslookup', 'www.python.org'])
print('Exit code:', r)

运行结果:

$ nslookup www.python.org
Server:        192.168.19.4
Address:    192.168.19.4#53

Non-authoritative answer:
www.python.org    canonical name = python.map.fastly.net.
Name:    python.map.fastly.net
Address: 199.27.79.223

Exit code: 0

如果子进程还需要输入,则可以通过communicate()方法输入:

import subprocess

print('$ nslookup')
p = subprocess.Popen(['nslookup'], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
output, err = p.communicate(b'set q=mx\npython.org\nexit\n')
print(output.decode('utf-8'))
print('Exit code:', p.returncode)

上面的代码相当于在命令行执行命令nslookup,然后手动输入:

set q=mx
python.org
exit

运行结果如下:

$ nslookup
Server:        192.168.19.4
Address:    192.168.19.4#53

Non-authoritative answer:
python.org    mail exchanger = 50 mail.python.org.

Authoritative answers can be found from:
mail.python.org    internet address = 82.94.164.166
mail.python.org    has AAAA address 2001:888:2000:d::a6


Exit code: 0

进程之间肯定是需要通信的,Python的multiprocessing模块包装了底层的机制,提供了QueuePipes等多种方式来交换数据。以Queue为例,在父进程中创建两个子进程,一个往Queue里写数据,一个从Queue里读数据:

from multiprocessing import Process, Queue
import os, random, time


def put_in(q):
    print('the process for writing is: ', os.getpid())
    for x in ['A', 'B', 'C', 'D', 'E']:
        print(time.strftime('%H:%M:%S', time.localtime()), '将', x, '放入队列中')
        q.put(x)
        n = random.randint(3, 8)
        print('休息 %d 秒...' % n)
        time.sleep(n)


def read_out(q):
    print('the process for read is: ', os.getpid())
    while True:
        value = q.get(True)
        print(time.strftime('%H:%M:%S', time.localtime()), '从队列中取出元素:', value)


if __name__ == '__main__':
    q = Queue()
    a = Process(target=put_in, args=(q, ))
    b = Process(target=read_out, args=(q, ))
    a.start()
    b.start()
    a.join()
    b.terminate()
    print('Finishing......,see u again!')

 a进程中通过put_in函数向q队列中写入内容,为了方便测试使用了time.strftime来显示一下写入的时间。b进程中通过read_out函数从q队列中读出内容,读出时使用了q.get(True),这样当q队列中没有内容时代码会卡住,直到有内容可以读取。

当put_in方法结束完毕后,a进程的使命结束,而b进程使用的是while True死循环,方法永远不会执行结束,所以要使用进程的terminate函数显示的直接终止进程的执行。

在Unix/Linux下,multiprocessing模块封装了fork()调用,使我们不需要关注fork()的细节。由于Windows没有fork调用,因此,multiprocessing需要“模拟”出fork的效果,父进程所有Python对象都必须通过pickle序列化再传到子进程去,所有,如果multiprocessing在Windows下调用失败了,要先考虑是不是pickle失败了。

2. 线程

进程是由若干线程组成的,一个进程至少有一个线程。

Python的标准库提供了两个模块:_threadthreading_thread是低级模块,threading是高级模块,对_thread进行了封装。绝大多数情况下,我们只需要使用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。主线程中可以启动新的线程。

Python的threading模块有个current_thread()函数,它永远返回当前线程的实例。主线程实例的名字叫MainThread,子线程的名字在创建时指定,例子中用的LoopThread命名子线程。名字仅仅在打印时用来显示,完全没有其他意义,如果不起名字Python就自动给线程命名为Thread-1Thread-2……

多线程和多进程最大的不同在于,多进程中,同一个变量各自有一份拷贝存在于每个进程中,互不影响。而多线程中,变量由所有线程共享,变量可以被任何一个线程修改,因此,线程之间共享数据最大的危险在于多个线程同时改一个变量可能会把内容给改乱了。

来看看多个线程同时操作一个变量怎么把内容给改乱:

import time, threading

# 假定这是你的银行存款:
balance = 0

def change_it(n):
    # 先存后取,结果应该为0:
    global balance
    balance = balance + n
    balance = balance - n

def run_thread(n):
    for i in range(100000):
        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)

我们定义了一个共享变量balance,初始值为0。启动两个线程,都进行先存后取的操作,理论上结果也应该为0。但是,由于线程的调度是由操作系统决定的,当t1、t2交替执行时,只要循环次数足够多,balance的结果就不也一定是0了。

原因是因为balance = balance + n是分为两步进行的,第一步计算,第二步赋值

1. 计算balance + n,存入临时变量中;
2. 将临时变量的值赋给balance。

考虑下面的一种情况:

初始时balance的值为0,此时t1线程执行加5的操作,在第一步操作完尚未进行第二步赋值时,进行了上下文的切换,t2开始执行。因为t1尚未给balance赋值,所以t2此时看到的balance也是0,现在t1执行加8的操作,随后进行了第二步赋值,将balance的值变为了8。此时进行上下文的切换,t1又开始执行,t1要执行的是赋值操作,于是将刚刚被t2赋值为8的balance变量又赋值为了5。随后t1继续执行减法操作减去5,,然后将0赋值给balance。此时上下文切换。t2开始执行,t2接下来也要执行减法操作和赋值,此时t2看到的balance是0,减去8后赋值,balance的值变为了-8。

# 初始值 balance = 0

t1: x1 = balance + 5  # x1 = 0 + 5 = 5

t2: x2 = balance + 8  # x2 = 0 + 8 = 8
t2: balance = x2      # balance = 8

t1: balance = x1      # balance = 5
t1: x1 = balance - 5  # x1 = 5 - 5 = 0
t1: balance = x1      # balance = 0

t2: x2 = balance - 8  # x2 = 0 - 8 = -8
t2: balance = x2   # balance = -8

我们无法预知什么时候会进行上下文的切换,因此这样的代码是不安全的。

究其原因,是因为修改balance需要多条语句,而执行这几条语句时线程可能中断,从而导致多个线程把同一个对象的内容改乱了。所以,我们必须确保一个线程在操作balance的时候,别的线程一定不能中途乱入。此时就要给change_it上一把锁。锁只有一个,同一时刻只有获得该锁的那一个线程可以执行change_it函数,其他线程只能等待。等到锁被释放,获得该锁的线程才能继续执行change_it函数。所以,使用锁就不会造成修改的冲突。

创建一个锁就是通过threading.Lock()来实现:

balance = 0
lock = threading.Lock()

def run_thread(n):
    for i in range(100000):
        # 先要获取锁:
        lock.acquire()
        try:
            # 放心地改吧:
            change_it(n)
        finally:
            # 改完了一定要释放锁:
            lock.release()

获得锁的线程用完后一定要释放锁,否则那些苦苦等待锁的线程将永远等待下去,成为死线程。所以我们用try...finally来确保锁一定会被释放。

锁的好处就是确保了某段关键代码只能由一个线程从头到尾完整地执行。当然坏处也很多:

首先是阻止了多线程并发执行,包含锁的某段代码实际上只能以单线程模式执行,效率就大大地下降了。

其次,由于可以存在多个锁,不同的线程持有不同的锁,当双方都试图获取对方持有的锁时,可能会造成互锁,导致多个线程全部挂起,既不能执行,也无法结束。

还有下面的这种自锁的状况:

import threading
lock = threading.Lock() 
#Lock对象
lock.acquire()
lock.acquire() 
#产生了死琐。
lock.release()
lock.release()

上面的代码会产生自锁。主线程在等待一个“永远”没法被释放掉的锁。

为了解决上述这种自锁的“无厘头”现象,有了重入锁RLock。 RLock允许在同一线程中被多次acquire。而Lock是不允许这种情况的。

import threading
rLock = threading.RLock() 
#RLock对象
rLock.acquire()
#在同一线程内,可以重新获取
rLock.acquire() 
rLock.release()
rLock.release()

除了Lock和RLock之外,threading还提供了一些其它的控制线程间通信的机制,我们一一来看:

Condition:

可以把Condiftion理解为一把高级的琐,它在内部维护一个琐对象(默认是RLock,也可以显式的使用Lock)。Condition提供了acquire, release方法,其含义与琐的acquire, release方法一致(其实它只是简单的调用内部琐对象的对应的方法而已)。同时Condition还提供了wait方法、notify方法和notifyAll方法。

wait([timeout]):

在线程获得锁的情况下,通过调用wait方法可以把线程挂起,直到收到一个notify通知或者超时(可选的参数,单位是秒s)才会被唤醒继续运行。wait()必须在已获得Lock前提下才能调用,否则会触发RuntimeError。调用wait()会释放Lock,并进入由Condition对象维护的一个线程池中,这个线程池中都是处于挂起等待醒来的线程。

notify(n=1):

通知Condition线程池中随机的n(默认为1)个挂起线程“苏醒”过来。与wait函数一样,notify必须在已获得Lock前提下才能调用,否则会触发RuntimeError。特别要注意的是,与wait函数不同,调用notify函数仅仅是唤醒其它线程,但不会主动释放锁。所以在发出通知后,应该注意要收到释放当前线程持有的锁。被唤醒的线程并不意味着其wait函数就返回了,必须是被唤醒的线程重新获得了Condition中的内部锁之时,它的wait函数才算返回(执行完毕)并继续执行wait函数后面的代码。而那些虽然被唤醒但是没有获得锁的线程依然在等待着。

notify_all():

notifyAll的作用就是通知Condition线程池中所有挂起的线程苏醒过来。

现在写个捉迷藏的游戏来介绍threading.Condition的基本使用。假设这个游戏由两个线程来玩,一个藏(Hider),一个找(Seeker)。游戏开始之后,Seeker先把自己眼睛蒙上然后就通知Hider;Hider接收通知后开始找地方将自己藏起来,藏好之后,再通知Seeker可以找了;Seeker接收到通知之后,就开始找Hider。显然游戏过程中两者之间的行为有一定的时序关系,我们通过Condition来控制这种时序关系:

import threading, time

def Seeker(cond, name):
    time.sleep(2)
    cond.acquire()
    print('%s :我已经把眼睛蒙上了!'% name)
    cond.notify()
    cond.wait()
    for i in range(3):
        print('%s is finding!!!'% name)
        time.sleep(2)
    cond.notify()
    cond.release()
    print('%s :我赢了!'% name)

def Hider(cond, name):
    cond.acquire()
    cond.wait()
    for i in range(2):
        print('%s is hiding!!!'% name)
        time.sleep(3)
    print('%s :我已经藏好了,你快来找我吧!'% name)
    cond.notify()
    cond.wait()
    cond.release()
    print('%s :被你找到了,唉~^~!'% name)


if __name__ == '__main__':
    cond = threading.Condition()
    seeker = threading.Thread(target=Seeker, args=(cond, 'seeker'))
    hider = threading.Thread(target=Hider, args=(cond, 'hider'))
    seeker.start()
    hider.start()

程序运行结果是:

seeker :我已经把眼睛蒙上了!
hider is hiding!!!
hider is hiding!!!
hider :我已经藏好了,你快来找我吧!
seeker is finding!!!
seeker is finding!!!
seeker is finding!!!
seeker :我赢了!
hider :被你找到了,唉~^~!

线程启动的时候,seeker睡眠两秒,因此hider可以毫无悬念的拿到cond中的锁。随后hider马上调用wait函数,将锁释放并将自己挂起。当seeker睡足两秒后,可以马上获得锁(因为另一个线程处于挂起状态,无法参与锁的争夺),seeker打印“我已经把眼睛蒙上了”之后,调用notify唤醒cond线程中的hider(因为此时线程池中就一个hider线程),然后调用wait释放锁并将自己挂起。这样hider也无悬念的获得了锁,此时hider从挂起位置的后面开始执行,打印两遍“hider is hiding!!!”,打印“我已经藏好了,你快来找我吧!”,然后通知seeker,并把自己的锁释放挂起自己。这样苏醒的seeker毫无悬念的拿到锁,从挂起位置的后面开始执行,打印seeker is finding!!!,睡足2秒,重复三次,然后通知hider,随后调用release只释放锁并不将自己挂起,因此此时seeker线程可以继续执行下去,打印“我赢了”。hider被唤醒后,毫无悬念的获得seeker release掉的锁,然后马上也执行release释放掉锁,并且不会将自己挂起直接打印“被你找到了,唉~”。

Semaphore和BoundedSemaphore

Semaphore 在内部管理着一个计数器。

调用 Semaphore的acquire函数会使这个计数器 -1,调用release函数则是+1。而且可以多次调用release函数让计数器的值理论上可以无上限增加。计数器的值永远不会小于 0,当计数器到 0 时,再调用 acquire() 就会阻塞,直到有其他线程调用release函数。

import threading, time

num = 0
start = time.time()
sem = threading.Semaphore(2)


def run(n):
    global num
    sem.acquire()
    time.sleep(1)
    print('run the thread-%d' % n)
    num += 1
    sem.release()


for x in range(20):
    t = threading.Thread(target=run, args=(x, ))
    t.start()

while num != 20:
    pass
print('all threading running finish')
print('运行时长:', time.time() - start)

初始是sem中的计数器数值为2,意味着可以有两个线程可以获得执行。而其余的18个线程调用acquire的时候会被挂起,等待有线程执行release增加计数器的值,才能让acquire函数返回并继续执行后面的代码。

主线程通过全局变量num的值检测是否20个子线程都执行完毕了,执行完毕后打印一下运行时间发现总时长大概是10秒左右。证明确实同一时间有两个线程处于运行状态。

BoundedSemaphore会检查内部计数器的值保证它不会大于初始值。如果超过了会引发ValueError。这主要是针对semaphore release多次的问题。

Event

Event有一个Flag旗标.如果Flag值为 False,那么调用event.wait函数会产生阻塞;如果Flag值为True,那么调用event.wait函数便不会阻塞。

Event的clear函数用来将Flag设置为False,set函数用来将Flag设置为True。使用threading.Event也可以实现线程间通信。下面是一个红绿灯的例子。启动一个线程做交通指挥灯,生成几个线程做车辆,车辆红灯停,绿灯行:

import threading, time, random

event = threading.Event()


def light():
    if not event.is_set():
        event.set()
    count = 0
    while True:
        if count < 10:
            print('----====绿灯====----')
        elif count < 13:
            print('oooooooo黄灯oooooooo')
        elif count < 20:
            if event.is_set():
                event.clear()
            print('xxxxXXXX红灯XXXXxxxx')
        else:
            count = 0
            event.set()
        time.sleep(1)
        count += 1


def car(n):
    while True:
        time.sleep(random.randint(3, 10))
        if event.is_set():
            print('car %s is running....' % n)
        else:
            print('car %s is waiting red light!' % n)
            event.wait()


threading.Thread(target=light).start()
cars = ['Audi', 'BMW', 'Benz', 'Bus', 'Volksvagen', 'Nissan', 'Honda']
for c in cars:
    threading.Thread(target=car, args=(c, )).start()

代码中用一个线程执行light函数,light函数刚开始执行的时候会检查一下event是否为True,如果不是要设置为True。然后进入一个死循环,逻辑很简单,count值小于13的时候不会改变event的Flag,当count介于13到19时,将evnet的Flag设置为False,超过19时会将count置0并设置event的Flag设置为True。

另外会有七个线程模拟汽车,每个汽车线程会随机睡3~10秒,醒来后会检查event的Flag值,如果Flag值为True则打印"car is running",如果Flag值为False,则打印"car is waiting red light",并且调用event.wait函数一直等待,直到变灯后(即event的Flag从False变为了True),wait函数才会返回,汽车线程继续执行wait函数后面的代码。

最后在了解一下threading提供的一些其它相关函数:

常用函数
函数名称作用
activate_count()当前存活的线程对象的数量
current_thread()返回当前线程对象
enumerate()返回当前存在的所有线程对象的列表
get_ident()返回线程的pid
main_thread()返回主线程对象

 

 

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值