第二十一篇 Python 多进程与多线程

853 阅读13分钟

1 基础知识

现在的 PC 都是多核的,使用多线程能充分利用 CPU 来提供程序的执行效率。

1.1 线程

线程是一个基本的 CPU 执行单元。它必须依托于进程存活。一个线程是一个execution context(执行上下文) ,即一个 CPU 执行时所需要的一串指令。

1.2 进程

进程是指一个程序在给定数据集合上的一次执行过程,是系统进行资源分配和运行调用的独立单位。可以简单地理解为操作系统中正在执行的程序。也就说,每个应用程序都有一个自己的进程。

每一个进程启动时都会最先产生一个线程,即主线程。然后主线程会再创建其他的子线程。

1.3 两者的区别

  • 线程必须在某个进程中执行。
  • 一个进程可包含多个线程,其中有且只有一个主线程。
  • 多线程共享同个地址空间、打开的文件以及其他资源。
  • 多进程共享物理内存、磁盘、打印机以及其他资源。

1.4 线程的类型

线程因作用可以划分为不同的类型。大致可分为:

  • 主线程
  • 子线程
  • 守护线程(后台线程)
  • 前台线程

2 Python 多线程

2.1 GIL

其他语言,CPU 是多核时是支持多个线程同时执行。但在 Python 中,无论是单核还是多核,同时只能由一个线程在执行。其根源是 GIL 的存在。

GIL 的全称是 Global Interpreter Lock(全局解释器锁),来源是 Python 设计之初的考虑,为了数据安全所做的决定。某个线程想要执行,必须先拿到 GIL,我们可以把 GIL 看作是“通行证”,并且在一个 Python 进程中,GIL 只有一个。拿不到通行证的线程,就不允许进入 CPU 执行。

GIL 只在 CPython 中才有,而在 PyPy 和 Jython 中是没有 GIL 的。

每次释放 GIL锁,线程进行锁竞争、切换线程,会消耗资源。这就是打印线程执行时长,会发现耗时更长的原因。

并且由于 GIL 锁存在,Python 里一个进程永远只能同时执行一个线程(拿到 GIL 的线程才能执行),这就是为什么在多核CPU上,Python 的多线程效率并不高的根本原因。

2.2 创建多线程

Python提供两个模块进行多线程的操作,分别是threadthreading,前者是比较低级的模块,用于更底层的操作,一般应用级别的开发不常用。

  • 方法1:直接使用threading.Thread()
import threading

# 这个函数名可随便定义
def run(n):
    print("current task:", n)

if __name__ == "__main__":
    t1 = threading.Thread(target=run, args=("thread 1",))
    t2 = threading.Thread(target=run, args=("thread 2",))
    t1.start()  # 启动子线程实例(创建子线程)
    t2.start()  # 启动子线程实例
  • 方法2:继承threading.Thread来自定义线程类,重写run方法
import threading

class MyThread(threading.Thread):
    def __init__(self, n):
        super(MyThread, self).__init__()  # 重构run函数必须要写
        self.n = n

    def run(self):
        print("current task:", n)

if __name__ == "__main__":
    t1 = MyThread("thread 1")
    t2 = MyThread("thread 2")

    t1.start()
    t2.start()

2.3 查看获取的线程列表

  1. threading.current_thread() 获取当前执行代码的线程
  2. threading.enumerate() 获取当前程序活动线程的列表。只有线程启动,线程才会加入到活动列表。
import threading
import time

# 唱歌任务
def sing(count):
    for i in range(count):
        print("正在唱歌{}".format(i))
        time.sleep(1)

# 跳舞任务
def dance(count):
    for i in range(count):
        print("正在跳舞{}".format(i))

if __name__ == "__main__":
    # 获取当前执行代码的线程(主线程)
    print("主线程:", threading.current_thread())
    # 获取当前活动的线程
    thread_list = threading.enumerate()
    print("未创建和执行子线程时:", thread_list)
    # 创建唱歌线程
    sing_thread = threading.Thread(target=sing, args=(3, ), name="唱歌任务")
    # 创建跳舞线程
    dance_thread = threading.Thread(target=dance, kwargs={"count": 3}, name="跳舞任务")

    thread_list = threading.enumerate()
    print("创建和未执行子线程时:", thread_list)

    #开启线程
    sing_thread.start()
    dance_thread.start()

    thread_list = threading.enumerate()
    print("创建和执行子线程时:", thread_list)

2.4 线程合并

Join函数执行顺序是逐个执行每个线程,执行完毕后继续往下执行。主线程结束后,子线程还在运行,join函数使得主线程等到子线程结束时才退出。

import threading

# 定义全局变量
g_num = 0


# 循环1000000次每次给全局变量加1
def sum_num1():
    for i in range(1000000):
        global g_num
        g_num += 1

    print("sum1:", g_num)


# 循环1000000次每次给全局变量加1
def sum_num2():
    for i in range(1000000):
        global g_num
        g_num += 1
    print("sum2:", g_num)


if __name__ == '__main__':
    # 创建两个线程
    first_thread = threading.Thread(target=sum_num1)
    second_thread = threading.Thread(target=sum_num2)

    # 启动线程
    first_thread.start()
    # 主线程等待第一个线程执行完成以后代码再继续执行,让其执行第二个线程
    # 线程同步: 一个任务执行完成以后另外一个任务才能执行,同一个时刻只有一个任务在执行
    first_thread.join()
    # 启动线程
    second_thread.start()

2.5 线程同步与互斥锁

线程之间数据共享的。当多个线程对某一个共享数据进行操作时,就需要考虑到线程安全问题。threading模块中定义了Lock 类,提供了互斥锁的功能来保证多线程情况下数据的正确性。

用法的基本步骤:

#创建锁
mutex = threading.Lock()
#锁定
mutex.acquire([timeout])
#释放
mutex.release()

其中,锁定方法acquire可以有一个超时时间的可选参数timeout。如果设定了timeout,则在超时后通过返回值可以判断是否得到了锁,从而可以进行一些其他的处理。

具体用法见示例代码:

import threading
import time

num = 0
mutex = threading.Lock()

class MyThread(threading.Thread):
    def run(self):
        global num 
        time.sleep(1)

        if mutex.acquire(1):  
            num = num + 1
            msg = self.name + ': num value is ' + str(num)
            print(msg)
            mutex.release()

if __name__ == '__main__':
    for i in range(5):
        t = MyThread()
        t.start()

2.6 守护线程

如果希望主线程执行完毕之后,不管子线程是否执行完毕都随着主线程一起结束。我们可以使用setDaemon(bool)函数,它跟join函数是相反的。它的作用是设置子线程是否随主线程一起结束,必须在start() 之前调用,默认为False

import threading
import time


# 测试主线程是否会等待子线程执行完成以后程序再退出
def show_info():
    for i in range(5):
        print("test:", i)
        time.sleep(0.5)


if __name__ == '__main__':
    # 创建子线程守护主线程 
    # daemon=True 守护主线程
    # 守护主线程方式1
    sub_thread = threading.Thread(target=show_info, daemon=True)
    # 设置成为守护主线程,主线程退出后子线程直接销毁不再执行子线程的代码
    # 守护主线程方式2
    # sub_thread.setDaemon(True)
    sub_thread.start()

    # 主线程延时1秒
    time.sleep(1)
    print("over")

2.7 定时器

如果需要规定函数在多少秒后执行某个操作,需要用到Timer类。具体用法如下:

**

from threading import Timer
 
def show():
    print("Pyhton")

# 指定一秒钟之后执行 show 函数
t = Timer(1, hello)
t.start()  

3 Python 多进程

3.1 创建多进程

Python 要进行多进程操作,需要用到muiltprocessing库,其中的Process类跟threading模块的Thread类很相似。所以直接看代码熟悉多进程。

  • 方法1:直接使用Process, 代码如下:
from multiprocessing import Process  

def show(name):
    print("Process name is " + name)

if __name__ == "__main__": 
    proc = Process(target=show, args=('subprocess',))  
    proc.start()  
    proc.join()
  • 方法2:继承Process来自定义进程类,重写run方法, 代码如下:
from multiprocessing import Process
import time

class MyProcess(Process):
    def __init__(self, name):
        super(MyProcess, self).__init__()
        self.name = name

    def run(self):
        print('process name :' + str(self.name))
        time.sleep(1)

if __name__ == '__main__':
    p = MyProcess("进程1")
    p.start()

3.2 进程间通信-Queue

进程之间不共享数据。如果进程之间需要进行通信,则要用到Queue模块来实现。

3.2.1 Queue的作用

  • 1.Python的Queue模块提供一种适用于多线程编程的FIFO实现。
  • 2.完成多个线程之间的数据和消息的传递,多个线程可以共用同一个Queue实例。
  • 3.Queue的大小(元素的个数)可用来限制内存的使用
  • 4.完成多个进程之间的数据和消息的传递

3.2.2 Queue的使用

3.2.2.1 Queue的导入
from queue import Queue   # 适用于线程和协程
from multiprocessing import JoinableQueue as Queue  # 使用于进程
3.2.2.2 Queue的方法使用
q = Queue(maxsize=100) # maxsize为队列长度
item = {}
q.put_nowait(item) #不等待直接放,队列满的时候会报错
q.put(item) #放入数据,队列满的时候会阻塞等待
q.get_nowait() #不等待直接取,队列空的时候会报错
q.get() #取出数据,队列为空的时候会阻塞等待
q.qsize() #获取队列中现存数据的个数 
q.join() # 队列中维持了一个计数(初始为0),计数不为0时候让主线程阻塞等待,队列计数为0的时候才会继续往后执行
         # q.join()实际作用就是阻塞主线程,与task_done()配合使用
         # put()操作会让计数+1task_done()会让计数-1
         # 计数为0,才停止阻塞,让主线程继续执行
q.task_done() # put的时候计数+1,get不会-1,get需要和task_done 一起使用才会-1

3.3 进程池

3.3.1 进程池的概念

池子里面放的是进程,进程池会根据任务执行情况自动创建进程,而且尽量少创建进程,合理利用进程池中的进程完成多任务

当需要创建的子进程数量不多时,可以直接利用multiprocessing中的Process动态成生多个进程,但如果是上百甚至上千个目标,手动的去创建进程的工作量巨大,此时就可以用到multiprocessing模块提供的Pool方法。

初始化Pool时,可以指定一个最大进程数,当有新的请求提交到Pool中时,如果池还没有满,那么就会创建一个新的进程用来执行该请求;但如果池中的进程数已经达到指定的最大值,那么该请求就会等待,直到池中有进程结束,才会用之前的进程来执行新的任务.

3.3.2 进程池同步执行任务

进程池同步执行任务表示进程池中的进程在执行任务的时候一个执行完成另外一个才能执行,如果没有执行完会等待上一个进程执行。

import multiprocessing
import time


# 拷贝任务
def work():
    print("复制中...", multiprocessing.current_process().pid)
    time.sleep(0.5)

if __name__ == '__main__':
    # 创建进程池
    # 3:进程池中进程的最大个数
    pool = multiprocessing.Pool(3)
    # 模拟大批量的任务,让进程池去执行
    for i in range(5):
        # 循环让进程池执行对应的work任务
        # 同步执行任务,一个任务执行完成以后另外一个任务才能执行
        pool.apply(work)

3.3.3 进程池异步执行任务

进程池异步执行任务表示进程池中的进程同时执行任务,进程之间不会等待

# 进程池:池子里面放的进程,进程池会根据任务执行情况自动创建进程,而且尽量少创建进程,合理利用进程池中的进程完成多任务
import multiprocessing
import time


# 拷贝任务
def work():
    print("复制中...", multiprocessing.current_process().pid)
    # 获取当前进程的守护状态
    # 提示:使用进程池创建的进程是守护主进程的状态,默认自己通过Process创建的进程是不是守住主进程的状态
    # print(multiprocessing.current_process().daemon)
    time.sleep(0.5)

if __name__ == '__main__':
    # 创建进程池
    # 3:进程池中进程的最大个数
    pool = multiprocessing.Pool(3)
    # 模拟大批量的任务,让进程池去执行
    for i in range(5):
        # 循环让进程池执行对应的work任务
        # 同步执行任务,一个任务执行完成以后另外一个任务才能执行
        # pool.apply(work)
        # 异步执行,任务执行不会等待,多个任务一起执行
        pool.apply_async(work)

    # 关闭进程池,意思告诉主进程以后不会有新的任务添加进来
    pool.close()
    # 主进程等待进程池执行完成以后程序再退出
    pool.join()

创建多个进程,我们不用傻傻地一个个去创建。我们可以使用Pool模块来搞定。

Pool 常用的方法如下:

方法含义
apply()同步执行(串行)
apply_async()异步执行(并行)
terminate()不管任务是否完成,立即终止
join()主进程阻塞,等待子进程的退出, 必须在close或terminate之后使用
close()关闭Pool,使其不再接受新的任务

4 选择多线程还是多进程?

在这个问题上,首先要看下你的程序是属于哪种类型的。一般分为两种 CPU 密集型 和 I/O 密集型。

  • CPU 密集型:程序比较偏重于计算,需要经常使用 CPU 来运算。例如科学计算的程序,机器学习的程序等。
  • I/O 密集型:顾名思义就是程序需要频繁进行输入输出操作。爬虫程序就是典型的 I/O 密集型程序。

如果程序是属于 CPU 密集型,建议使用多进程。而多线程就更适合应用于 I/O 密集型程序。

5 多进程爬虫

5.1 多进程爬虫介绍

Python的多线程爬虫只能运行在单核上,各个线程以并发的方法运行。由于GIL(全局解释器锁)的存在,多线程爬虫并不能充分地发挥多核CPU的资源。

作为提升Python网络爬虫速度的另一种方法,多进程爬虫则可以利用CPU的多核,进程数取决于计算机CPU的处理器个数。由于运行在不同的核上,各个进程的运行是并行的。在Python中,如果我们要用多进程,就要用到multiprocessing这个库。

使用multiprocessing库有两种方法,一种是使用Process + Queue的方法,另一种方法是使用Pool + Queue的方法。

5.2 使用multiprocessing的多进程爬虫

multiprocessing对于习惯使用threading多线程的用户非常友好,因为它的理念是像线程一样管理进程,和threading很像,而且对于多核CPU的利用率比threading高得多。 当进程数量大于CPU的内核数量时,等待运行的进程会等到其他进程运行完毕让出内核为止。因此,如果CPU是单核,就无法进行多进程并行。 我们可以通过下面的函数了解我们电脑CPU的核心数量:

from multiprocessing import cpu_count
print(cpu_count())

结果是8,说明我的电脑是8核。

爬虫代码:

# -*- coding: utf-8 -*-
import time
import requests

from lxml import etree
from multiprocessing import Process, JoinableQueue


class Spider(object):
    def __init__(self):
        # 存储一级链接的队列
        self.all_url_queue = JoinableQueue()
        # 存储每个请求url的响应数据
        self.response_queue = JoinableQueue()

    def get_all_url(self):
        """组合每一个需要发送网络请求的url"""
        for i in range(1, 20):
            self.all_url_queue.put("http://www.1ppt.com/xiazai/zongjie/ppt_zongjie_{}.html".format(i))

    def get_html(self):
        """对url发送请求得到响应,并将其转换为Element对象"""
        while True:
            url = self.all_url_queue.get()
            print("请求:", url)
            response = requests.get(url)
            # html = etree.HTML(response.content)
            self.response_queue.put(response.content)
            self.all_url_queue.task_done()
            print("完毕")

    def get_data(self):
        """获取每个Element对象的数据,并将数据打印"""
        while True:
            # html = self.html_queue.get()
            html = etree.HTML(self.response_queue.get())
            a_list = html.xpath('//ul[@class="tplist"]//h2/a')
            for i in a_list:
                item = {}
                item['title'] = i.xpath('./text()')[0]
                item['url'] = i.xpath('./@href')[0]
                print(item)
            self.response_queue.task_done()

    def run(self):
        # 创建一个存放开辟所有线程的列表
        process_list = []
        # 整理需要爬取的一级链接
        self.get_all_url()
        # 开辟5个线程处理all_url_queue中的数据,并将开辟的线程添加到线程列表中
        for i in range(10):
            get_html_thread = Process(target=self.get_html, daemon=True)
            process_list.append(get_html_thread)
        # 开辟3个线程处理response_queue中的数据,并将开辟的线程添加到线程列表中
        for i in range(3):
            get_data_thread = Process(target=self.get_data, daemon=True)
            process_list.append(get_data_thread)
        # 将开辟的所有线程迭代启动
        for _process in process_list:
            _process.start()
        # 由于线程的函数是死循环,如果让线程阻塞主线程会导致,主线程无法退出,因此为了能执行任务和主线程安全退出,需要使用队列来阻塞主线程
        self.all_url_queue.join()
        self.response_queue.join()
        print("爬虫程序运行结束")


if __name__ == '__main__':
    # 爬虫启动时间
    start_time = time.time()
    spider = Spider()
    spider.run()
    # 爬虫结束时间
    end_time = time.time()
    # 爬虫总耗时
    print("爬虫总耗时", end_time - start_time)
    # 爬虫总耗时 4.6992528438568115