多进程、多线程、协程

多进程、多线程、协程

  上一次我们最后生成器函数来生成简单的协程应用,在使用web框架tornado也涉及协程编程,这里重新复习下协程相关内容。要说到协程,需要了解下程序、进程、线程。

一、相关基础

  **1.程序:**指为完成某项或多项特定工作的计算机程序,程序是用于实现某种功能的一组指令的有序集合(只不过是磁盘中可执行的二进制(或者其他类型)的数据);应用程序运行于操作系统之上(只有将程序装载到内存中,系统为它分配了资源并被操作系统调用的时候才开始它们的生命周期,即运行)也可以说程序就是存在的静态文件,放置在那。比如下载的游戏文件夹中存放的各种运行文件。

  **2.进程:**具有一定独立功能的程序关于某个数据集合的一次运行活动,是系统进行资源分配和调度的一个独立单位,是应用程序运行的载体。一个进程至少一个程序对应,一个程序可能产生多个进程,也可以没有与之对应的进程(没有运行)。比如打开下载的游戏程序,运行起来,会产生多个进程,主程序需要一个进程,功能模块需要一个进程等。

  **3.线程:**线程是CPU调度的最小单位(程序执行流的最小单元),它被包含在进程之中,是进程中的实际运作单元。一条线程是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。一个标准的线程由线程ID、当前指令指针(PC)、寄存器和堆栈组成。而进程由内存空间(代码、数据、进程空间、打开的文件)和一个或多个线程组成。

  线程有开始顺序执行结束三部分。它有一个自己的指令指针,记录自己运行到什么地方。线程的运行可能被占用(中断),或者暂时的被挂起(睡眠),让其他的线程运行,这叫做让步。一个进程中的各个线程之间共享同一片数据空间,所以线程之间可以比进程之间更加**方便的共享数据以及相互通讯。**比如游戏启动后,页面加载进程启动,包含着页面数据加载,展示插件加载,音乐播放等多个子任务,即多个线程。

4.线程与进程关系

  • 一个线程只能属于一个进程,而一个进程可以有多个线程,但至少有一个线程;
  • 资源分配给进程,同一进程内的所有线程共享该进程的所有资源;
  • 线程在执行过程中需要协作同步。不同进程中的线程之间要利用消息通信的方法实现同步;
  • 处理机分配给线程,即真正在处理机上运行的是线程;
    线程从属于进程,是程序的实际执行者。一个进程至少包含一个主线程,也可以有更多的子线程。线程拥有自己的栈空间。可查看下图清晰关系:

5.串行
  串行由A和B两个任务,A和B两个任务运行在一个CPU线程上,在A任务执行完之前不可以执行B。就像要玩电脑游戏,你必须先打开电脑,才能开始,电脑打开必须执行完。

6.并行:
  并行的话是指A和B两个任务可以同时进行。比如打开电脑,可以玩游戏的同时也可以同时听歌。

7.并发:
  并发是指资源有限的情况下,两者交替轮流使用资源。 

二、操作系统方面的多进程、多线程、协程

  现代操作系统比如Mac OS X,UNIX,Linux,Windows等,都是支持“多任务”的操作系统。“多任务”简单地说,操作系统可以同时运行多个任务。前面所说的一边玩游戏,一边听歌就属于多任务方式,当然操作系统的后台同样还有许多任务在执行着。电脑的cpu作为处理任务的载体,多个cpu可以处理多个任务,单个cpu也可以执行多任务,操作系统轮流让各个任务交替执行,cpu处理速度很快,就感觉像在同时进行,真正的并行执行多任务只能在多核CPU上实现,但是,由于任务数量远远多于CPU的核心数量,所以,操作系统也会自动把很多任务轮流调度到每个核心上执行,这就多进程过程。

  对于操作系统来说,一个任务就是一个进程。有些进程还不止同时干一件事,比如Word,它可以同时进行打字、拼写检查、打印等事情。在一个进程内部,要同时干多件事,就需要同时运行多个“子任务”,我们把进程内的这些“子任务”称为线程(Thread)。由于每个进程至少要干一件事,所以,一个进程至少有一个线程。当然,像Word这种复杂的进程可以有多个线程,多个线程可以同时执行,多线程的执行方式和多进程是一样的,也是由操作系统在多个线程之间快速切换,让每个线程都短暂地交替运行,看起来就像同时执行一样。当然,真正地同时执行多线程需要多核CPU才可能实现。

  多线程的优点:无需跨进程边界; 程序逻辑和控制方式简单;所有线程可以直接共享内存和变量等;所有线程可以直接共享内存和变量等;

  缺点:每个线程与主程序共用地址空间,受限于2GB地址空间;线程之间的同步和加锁控制比较麻烦; 一个线程的崩溃可能影响到整个程序的稳定性;到达一定的线程数程度后,即使再增加CPU也无法提高性能,例如Windows Server 2003,大约是1500个左右的线程数就快到极限了(线程堆栈设定为1M),如果设定线程堆栈为2M,还达不到1500个线程总数;线程能够提高的总性能有限,而且线程多了之后,线程本身的调度也是一个麻烦事儿,需要消耗较多的CPU 。

  线程之间的同步和加锁控制比较麻烦; 一个线程的崩溃可能影响到整个程序的稳定性;所以出来了协程:协程,英文名Coroutine。协程的概念很早就提出来了,但直到最近几年才在某些语言(如Lua)中得到广泛应用。子程序,或者称为函数,在所有语言中都是层级调用,比如A调用B,B在执行过程中又调用了C,C执行完毕返回,B执行完毕返回,最后是A执行完毕。所以子程序调用是通过栈实现的,一个线程就是执行一个子程序。子程序调用总是一个入口,一次返回,调用顺序是明确的。而协程的调用和子程序不同。协程看上去也是子程序,但执行过程中,在子程序内部可中断,然后转而执行别的子程序,在适当的时候再返回来接着执行。

  协程的特点在于是一个线程执行,所以要使用协程就得有其自己的优势:其一、协程极高的执行效率。子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显;其二不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态,所以执行效率比多线程高很多。因为协程是一个线程执行,当然要使用多核cpu,需要新方式,最简单的方法是多进程+协程,既充分利用多核,又充分发挥协程的高效率,可获得极高的性能。

三、python的多进程、多线程、协程、锁机制

  了解了进程、线程的原理概念,这里我们主要看下python中的相关实现。

3.1 进程

  python实现进程,因为操作系统的不同可能还会有一点点差异。故而要了解各系统特色,Unix/Linux操作系统提供了一个fork()系统调用,fork()调用一次,返回两次,不同于普通函数,操作系统自动把当前进程(称为父进程)复制了一份(称为子进程),然后,分别在父进程和子进程内返回。

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

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

import os

pid = os.fork()
if pid < 0:
    print ('Fail to create process')
elif 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))  

输出结果:

I (8316) just created a child process (8516).
I am child process (8516) and my parent is (8316).

  虽然子进程复制了父进程的代码段和数据段等,但是一旦子进程开始运行,子进程和父进程就是相互独立的,它们之间不再共享任何数据。

  Python 提供了一个 multiprocessing 模块,可以来编写跨平台的多进程程序,但注意 multiprocessing 在 Windows 和 Linux 平台可能存在不一致性:一样的代码在 Windows 和 Linux 下运行的结果可能不同。因为 Windows 的进程模型和 Linux 不一样,Windows下没有fork。

例如在主进程中启动一个子进程,并等待其结束:

import os
from multiprocessing import Process

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

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

输出结果:

Parent process 7170.
Process will start.
Run child process test (10075)...
Process end.

上面的代码,从 multiprocessing 模块引入Process,Process 是一个用于创建进程对象的类,其中,target 指定了进程要执行的函数,args 指定了参数。创建进程实例p之后,调用start方法开始执行该子进程,又调用了join方法,该方法用于阻塞子进程以外所有进程(指父进程),当子进程执行完毕后,父进程才会继续执行,它通常用于进程间的同步。

  python中的multiprocessing还会和使用的平台有关系,相同的代码可能不同的平台会得到不一样的结果。

import random
import os
from multiprocessing import Process

num = random.randint(0, 100)

def show_num():
    print("pid:{}, num is {}".format(os.getpid(), num))

if __name__ == "__main__":
    print("pid:{}, num is {}".format(os.getpid(), num))
    p = Process(target=show_num)
    p.start()
    p.join()

在Linux下输出的结果:

pid:8316, num is 51
pid:8655, num is 51

在windows输出的结果:

pid:6504, num is 25
pid:6880, num is 6

可以看到不同平台上num值都会变得不一样。前面都说的是单进程,那么要创建多进程呢?往下进行;Python 提供了进程池的方式,让我们批量创建子进程,让我们看一个简单的示例:

import os, time
from multiprocessing import Pool

def foo(x):
    print( 'Run task %s (pid:%s)...' % (x, os.getpid()))
    time.sleep(2)
    print ('Task %s result is: %s' % (x, x * x))

if __name__ == '__main__':
    print ('Parent process %s.' % os.getpid())
    p = Pool(4)         # 设置进程数
    for i in range(5):
        p.apply_async(foo, args=(i,))    # 设置每个进程要执行的函数和参数
    print ('Waiting for all subprocesses done...')
    p.close()
    p.join()
    print ('All subprocesses done.')

输出结果:

Parent process 8316.
Waiting for all subprocesses done...
Run task 0 (pid:8713)...
Run task 1 (pid:8714)...
Run task 2 (pid:8715)...
Run task 3 (pid:8717)...
Task 0 result is: 0
Run task 4 (pid:8713)...
Task 3 result is: 9
Task 1 result is: 1
Task 2 result is: 4
Task 4 result is: 16
All subprocesses done.

上面代码Pool用于生成进程池,Pool对象调用 apply_async 方法可以使每个进程异步执行任务,不用等上一个任务执行完才执行下一个任务,close 方法用于关闭进程池,确保没有新的进程加入,join 方法会等待所有子进程执行完毕。

  进程间的通信可以通过管道(Pipe),队列(Queue)等多种方式来实现。Python 的 multiprocessing 模块封装了底层的实现机制,让我们可以很容易地实现进程间的通信。

下面以队列(Queue)为例,在父进程中创建两个子进程,一个往队列写数据,一个从对列读数据,代码如下:

from multiprocessing import Process, Queue

# 向队列中写入数据
def write_task(q):
    try:
        n = 1
        while n < 5:
            print ("write, %d" % n)
            q.put(n)
            time.sleep(1)
            n += 1
    except BaseException:
        print ("write_task error")
    finally:
        print ("write_task end")

# 从队列读取数据
def read_task(q):
    try:
        n = 1
        while n < 5:
            print ("read, %d" % q.get())
            time.sleep(1)
            n += 1
    except BaseException:
        print ("read_task error")
    finally:
        print ("read_task end")

if __name__ == "__main__":
    q = Queue()  # 父进程创建Queue,并传给各个子进程

    pw = Process(target=write_task, args=(q,))
    pr = Process(target=read_task, args=(q,))

    pw.start()   # 启动子进程 pw,写入
    pr.start()   # 启动子进程 pr,读取
    pw.join()    # 等待 pw 结束
    pr.join()    # 等待 pr 结束
    print ("DONE")

输出结果:

write, 1
read, 1
write, 2
read, 2
write, 3
read, 3
write, 4
read, 4
write_task end
read_task end
DONE

  正如前面的理论一样,每个进程都有各自的内存空间,数据栈等,所以只能使用进程间通讯(Inter-Process Communication, IPC),而不能直接共享信息。python中的multiprocessing存在各种实现,可以直接使用。

3.2 线程

  Python 中,进行多线程编程的模块有两个:thread 和 threading。其中,thread 是低级模块,threading 是高级模块,对 thread 进行了封装,一般来说,我们只需使用 threading 这个模块。

可以以看个示例:

from threading import Thread, current_thread

def thread_test(name):
    print('thread %s is running...' % current_thread().name)
    print ('hello', name)
    print ('thread %s ended.' % current_thread().name)

if __name__ == "__main__":
    print ('thread %s is running...' % current_thread().name)
    print ('hello world!')
    t = Thread(target=thread_test, args=("test",), name="TestThread")
    t.start()
    t.join()
    print ('thread %s ended.' % current_thread().name)

输出结果:

thread MainThread is running...
hello world!
thread TestThread is running...
hello test
thread TestThread ended.
thread MainThread ended.

  创建一个新的线程,就是把一个函数和函数参数传给 Thread 实例,然后调用 start 方法开始执行。代码中的 current_thread 用于返回当前线程的实例。

  由于同一个进程之间的线程是内存共享的,所以当多个线程对同一个变量进行修改的时候,可能和预想结果不一样。

from threading import Thread, current_thread

num = 0

def calc():
    global num
    print( 'thread %s is running...' % current_thread().name)
    for _ in range(10000):
        num += 1
    print('thread %s ended.' % current_thread().name)

if __name__ == '__main__':
    print('thread %s is running...' % current_thread().name)

    threads = []
    for i in range(5):
        threads.append(Thread(target=calc))
        threads[i].start()
    for i in range(5):
        threads[i].join()

    print ('global num: %d' % num)
    print ('thread %s ended.' % current_thread().name)

上述代码创建了 5 个线程,每个线程对全局变量 num 进行 10000 次的 加 1 操作,这里之所以要循环 10000 次,是为了延长单个线程的执行时间,使线程执行时能出现中断切换的情况。现在问题来了,当这 5 个线程执行完毕时,全局变量的值是多少呢?是 50000 吗?输出结果:

thread MainThread is running...
thread Thread-34 is running...
thread Thread-34 ended.
thread Thread-35 is running...
thread Thread-36 is running...
thread Thread-37 is running...
thread Thread-38 is running...
thread Thread-35 ended.
thread Thread-38 ended.
thread Thread-36 ended.
thread Thread-37 ended.
global num: 40228
thread MainThread ended.

  发现 num 的值是 40228,事实上,num 的值是不确定的,你再运行一遍,会发现结果变了。原因是因为 num += 1 不是一个原子操作,也就是说它在执行时被分成若干步:

  • 计算 num + 1,存入临时变量 tmp 中;
  • 将 tmp 的值赋给 num.

由于线程是交替运行的,线程在执行时可能中断,就会导致其他线程读到一个脏值。

  为了保证计算的准确性,这里就使用我们就需要给 num += 1 这个操作加上。当某个线程开始执行这个操作时,由于该线程获得了锁,因此其他线程不能同时执行该操作,只能等待,直到锁被释放,这样就可以避免修改的冲突。创建一个锁可以通过 threading.Lock() 来实现,代码如下:

from threading import Thread, current_thread, Lock

num = 0
lock = Lock()

def calc():
    global num
    print('thread %s is running...' % current_thread().name)
    for _ in range(10000):
        lock.acquire()    # 获取锁
        num += 1
        lock.release()    # 释放锁
    print('thread %s ended.' % current_thread().name)

if __name__ == '__main__':
    print('thread %s is running...' % current_thread().name)

    threads = []
    for i in range(5):
        threads.append(Thread(target=calc))
        threads[i].start()
    for i in range(5):
        threads[i].join()

    print('global num: %d' % num)
    print('thread %s ended.' % current_thread().name)

得到新的输出结果:

thread MainThread is running...
thread Thread-88 is running...
thread Thread-89 is running...
thread Thread-90 is running...
thread Thread-91 is running...
thread Thread-92 is running...
thread Thread-88 ended.
thread Thread-91 ended.
thread Thread-92 ended.
thread Thread-90 ended.
thread Thread-89 ended.
global num: 50000
thread MainThread ended.

  Python 中的多线程,就不得不面对 GIL 锁,GIL 锁的存在导致 Python 不能有效地使用多线程实现多核任务,因为在同一时间,只能有一个线程在运行。GIL 全称是 Global Interpreter Lock,译为全局解释锁。早期的 Python 为了支持多线程,引入了 GIL 锁,用于解决多线程之间数据共享和同步的问题。但这种实现方式后来被发现是非常低效的,当大家试图去除 GIL 的时候,却发现大量库代码已重度依赖 GIL,由于各种各样的历史原因,GIL 锁就一直保留到现在。Python解释器由于设计时有GIL全局锁,导致了多线程无法利用多核。多线程的并发在Python中就是一个美丽的梦。这里面还存在着很多故事,后续分解。

3.3 协程

  就像前面所说的与多线程相比,最大的优势就是协程极高的执行效率。因为子程序切换不是线程切换,而是由程序自身控制,因此,没有线程切换的开销,和多线程比,线程数量越多,协程的性能优势就越明显。也不需要多线程的锁机制,因为只有一个线程,也不存在同时写变量冲突,在协程中控制共享资源不加锁,只需要判断状态就好了,所以执行效率比多线程高很多。

  Python对协程的支持是通过生成器实现的。在generator中,我们不但可以通过for循环来迭代,还可以不断调用next()函数获取由yield语句返回的下一个值。Python的yield不但可以返回一个值,它还可以接收调用者发出的参数。还是前面的生产-消费者模型改用协程来实现,生产者生产消息后,直接通过yield跳转到消费者开始执行,待消费者执行完毕后,切换回生产者继续生产,效率极高:

def consumer():
    r = ''
    while True:
        n = yield r
        if not n:
            return
        print('[CONSUMER] Consuming %s...' % n)
        r = '200 OK'

def produce(c):
    c.send(None)
    n = 0
    while n < 5:
        n = n + 1
        print('[PRODUCER] Producing %s...' % n)
        r = c.send(n)
        print('[PRODUCER] Consumer return: %s' % r)
    c.close()

c = consumer()
produce(c)

输出结果:

[PRODUCER] Producing 1...
[CONSUMER] Consuming 1...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 2...
[CONSUMER] Consuming 2...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 3...
[CONSUMER] Consuming 3...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 4...
[CONSUMER] Consuming 4...
[PRODUCER] Consumer return: 200 OK
[PRODUCER] Producing 5...
[CONSUMER] Consuming 5...
[PRODUCER] Consumer return: 200 OK

注意到consumer函数是一个generator,把一个consumer传入produce后:

  1. 首先调用c.send(None)启动生成器;
  2. 然后,一旦生产了东西,通过c.send(n)切换到consumer执行;
  3. consumer通过yield拿到消息,处理,又通过yield把结果传回;
  4. produce拿到consumer处理的结果,继续生产下一条消息;
  5. produce决定不生产了,通过c.close()关闭consumer,整个过程结束。

整个流程无锁,由一个线程执行,produceconsumer协作完成任务,所以称为“协程”,而非线程的抢占式多任务。

总结:

  这里只是进行的进程、线程、协程的一些简单介绍,至少对理解增加了许多,当然要真正的理解还是需要理解更多底层细节。从系统调度等底层技术细节开始学习,也可以直接学习tornado底层实现来学习

参考链接:

1. https://blog.csdn.net/y_silence_/article/details/101605333#t6

2. http://www.ruanyifeng.com/blog/2013/04/processes_and_threads.html

3. http://blog.rainy.im/2016/04/07/python-thread-and-coroutine/

4. https://www.cnblogs.com/x54256/p/7684106.html

5. https://codingdict.com/article/8867

6. https://www.cnblogs.com/deeper/p/7730203.html

7. https://xiongyiming.blog.csdn.net/article/details/89415155#t16

8. https://wiki.jikexueyuan.com/project/explore-python/Process-Thread-Coroutine/thread.html

9. https://www.liaoxuefeng.com/wiki/1016959663602400/1017968846697824

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值