Python多线程详解

1 前言

本系列打算将 Python 中的线程、进程以及协程做一个全面的总结,本文目前是第一部分——多线程。在正式进入线程的讲解之前,我们先来熟悉一下相关的概念。

1.1 并发与并行

  • 并发:在操作系统中,某一时间段,几个程序在同一个CPU上运行,但在任意一个时间点上,只有一个程序在CPU上运行。
  • 并行:当操作系统有多个CPU时,一个CPU处理A线程,另一个CPU处理B线程,两个线程互相不抢占CPU资源,可以同时进行,这种方式成为并行。

即使是在单核CPU上,当程序运行速度足够快,虽然程序是在并发的交替执行,但是给我们带来的感受却像是在并行运行一样。Python 中有两种并发的形式—— threading 和 asyncio。其中 threading 就是我们今天要讲的多线程,而 asyncio(协程) 我们放到后面讲
并行和并发

1.2 进程、线程、协程

  • 进程:进程是执行中的程序(QQ在没有运行的情况下是应用程序,不是进程,只有我们双击运行它的时候,它才是进程),是系统进行资源分配和调度的基本单位,是操作系统结构的基础。
  • 线程:线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位,也被称为轻量级进程。
  • 协程:
    在这里插入图片描述

1.3 应用场景

  • 并发通常应用于I/O操作频繁的场景,比如你要从网站上下载多个文件,I/O操作的时间可能会比CPU运行处理的时间长得多。
  • 并行则更多应用于CPU heavy的场景,比如MapReduce中的并行计算,为了加快运行速度,一般会用多台机器、多个处理器来完成。
    在这里插入图片描述

2 线程介绍

2.1 什么是线程

线程:线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位,也被称为轻量级进程。

2.2 为什么使用多线程

已经有了进程,为什么还需要线程?为了回答这个问题,我们需要再回顾一下进程的定义:进程是系统进行资源分配和调度的基本单位。系统为每个进程都分配了资源,使得进程之间不受资源的干扰,提高了计算机的运行效率。但是进程还是有缺陷的,主要有以下两点:

  1. 进程只能在一个时间干一件事,如果想同时干两件事或多件事,进程就无能为力了。
  2. 进程在执行的过程中如果阻塞,整个进程就会挂起。

相比之下,多线程编程具有以下优点:

  1. 同个进程下的线程共享内存,所以同进程下的多线程之间通信方便;
  2. 同个进程下的不同线程之间可以代价更小的切换,从而实现并发执行;

2.3 进程与线程之间的区别

  • 进程是CPU资源分配的基本单位,线程是独立运行和独立调度的基本单位(CPU上真正运行的是线程)。
  • 进程拥有自己的资源空间,一个进程包含若干个线程,线程与CPU资源分配无关,多个线程共享同一进程内的资源。
  • 线程的调度与切换比进程快很多。
  • 多进程对于多CPU,多线程对应多核CPU。

3 多线程实现

3.1 普通创建方式

import threading
import time


def run(n):
    print("task", n)
    time.sleep(1)
    print(n + '-2s')
    time.sleep(1)
    print(n + '-1s')
    time.sleep(1)
    print(n + '-0s')
    time.sleep(1)


if __name__ == '__main__':
    t1 = threading.Thread(target=run, args=("t1",))  # 注意("t1",)是元组,一定要加逗号
    t2 = threading.Thread(target=run, args=("t2",))
    t1.start()
    t2.start()

运行结果如下:

task t1
task t2
t1-2s
t2-2s
t1-1s
t2-1s
t1-0s
t2-0s

这里插入一个小知识点:线程 start() 和 run() 方法的区别(Demo可以看这里):

  • start() 方法是启动一个子线程,线程名就是我们定义的name
  • run() 方法并不启动一个新线程,就是在主线程中调用了一个普通函数而已

3.2 自定义线程类

import threading
import time


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

    def run(self):
        print("task", self.n)
        time.sleep(1)
        print(self.n + '-2s')
        time.sleep(1)
        print(self.n + '-1s')
        time.sleep(1)
        print(self.n + '-0s')
        time.sleep(1)


if __name__ == '__main__':
    t1 = MyThread("t1")
    t2 = MyThread("t2")
    t1.start()
    t2.start()

运行结果如下:

task t1
task t2
t1-2s
t2-2s
t1-1s
t2-1s
t1-0s
t2-0s

3.3 守护线程

当其它非守护线程结束时,程序退出
在下面的代码中,我们使用 setDaemon(True) 将子线程设置为守护线程,所以当主线程结束时,子线程也会随之结束,进而整个程序退出。

import threading
import time


def run(n):
    print("task" + n)
    time.sleep(1)
    print(n + '-2s')
    time.sleep(1)
    print(n + '-1s')
    time.sleep(1)
    print(n + '-0s')
    time.sleep(1)


if __name__ == '__main__':
    t1 = threading.Thread(target=run, args=("t1",))  # 注意("t1",)是元组,一定要加逗号
    t2 = threading.Thread(target=run, args=("t2",))
    t1.setDaemon(True)
    t2.setDaemon(True)
    t1.start()
    t2.start()
    print("Done")

代码运行结果:

taskt1
taskt2
Done

3.4 主线程等待子线程结束

为了让守护线程执行结束之后,主线程再结束,我们可以使用join方法,让主线程等待子线程执行。

import threading
import time


def run(n):
    print("task" + n)
    time.sleep(1)
    print(n + '-2s')
    time.sleep(1)
    print(n + '-1s')
    time.sleep(1)
    print(n + '-0s')
    time.sleep(1)


if __name__ == '__main__':
    t1 = threading.Thread(target=run, args=("t1",))  # 注意("t1",)是元组,一定要加逗号
    t2 = threading.Thread(target=run, args=("t2",))
    t1.setDaemon(True)
    t2.setDaemon(True)
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print("Done")

代码运行结果如下:

taskt1
taskt2
t1-2s
t2-2s
t1-1s
t2-1s
t2-0s
t1-0s
Done

3.5 多线程共享全局变量

线程是进程的执行单元,进程是系统分配资源的最小单位,所以在同一个进程中的多线程是共享资源的。

import threading
import time

g_num = 0


def work(n):
    global g_num
    for i in range(3):
        g_num += 1
    print(f"in work{n} g_num is : {g_num}\n")


if __name__ == '__main__':
    t1 = threading.Thread(target=work, args=('1',))
    t2 = threading.Thread(target=work, args=('2',))
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print(f"in main g_num is : {g_num}")

运行结果:

in work1 g_num is : 3

in work2 g_num is : 6

in main g_num is : 6

3.6 互斥锁

由于线程之间是随机调度的,而且各个线程的执行时间不一定,当多个线程同时修改同一条数据时就可能发生意想不到的情况。如下例子,当每个函数都循环1000000次时,最终的g_num大概率不等于(小于)2000000

import threading
import time

g_num = 0


def work(n):
    global g_num
    for i in range(1000000):
        g_num += 1
    print(f"in work{n} g_num is : {g_num}\n")


if __name__ == '__main__':
    t1 = threading.Thread(target=work, args=('1',))
    t2 = threading.Thread(target=work, args=('2',))
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print(f"in main g_num is : {g_num}")

运行结果:

in work1 g_num is : 1422951

in work2 g_num is : 1496914

in main g_num is : 1496914

这是因为在计算机内部CPU执行 g_num += 1 这行代码时,会分为三个步骤执行:

  1. 先从内存中将 g_num 的值读取出来
  2. 将读取出的 g_num 值加1
  3. 将加1后的新值赋值给 g_num

正如我们刚刚提到的线程的是被随机调度的,而且执行的时间也不一定。假设 work1 线程此刻掌握着CPU的执行权,且此时的 g_num 值为100,当进行完前两步的读取、加1操作后,正准备将101的新值赋值给 g_num 的时候,work1 丧失了执行权,轮到 work2 执行。由于 work2 的执行时间也不一定,假设 work2 一直运行,将 g_num 的值累加到了 110 ,此时 work1 又重获CPU的执行权,那么此时直接从第三步开始执行,将 101 的值赋给 g_num 。所以最终g_num 很难累加到 2000000,当然也有一定的概率。

为了解决上述问题,引入互斥锁的概念,使用threading模块中的Lock,给可能冲突的代码段上锁、解锁。从而避免资源竞争引发的错误。如下代码所示:

import threading
import time

g_num = 0
lock = threading.Lock()


def work(n):
    global g_num
    for i in range(1000000):
        lock.acquire()
        g_num += 1
        lock.release()
    print(f"in work{n} g_num is : {g_num}\n")


if __name__ == '__main__':
    t1 = threading.Thread(target=work, args=('1',))
    t2 = threading.Thread(target=work, args=('2',))
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print(f"in main g_num is : {g_num}")

运行结果如下:

in work1 g_num is : 1852599

in work2 g_num is : 2000000

in main g_num is : 2000000

可以看到,最终的 g_num 结果等于2000000,但是运行效率低了很多,而且需要注意避免死锁的产生。

3.6.1 死锁

互斥锁可能造成的问题:死锁。所谓死锁: 是指两个或两个以上的进程在执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用,它们都将无法推进下去。形成死锁的两种情况:

  • 锁嵌套,指当一个线程在获取临界资源时,又需要再次获取;
  • 如果有多个公共资源,在线程间共享多个资源的时候,如果两个线程分别占有一部分资源并且同时等待对方的资源;
  1. 锁嵌套形成死锁代码如下:
import threading
import time

g_num = 0
lock = threading.Lock()



def work(n):
    global g_num
    for i in range(1000000):
        lock.acquire()
        lock.acquire()
        g_num += 1
        lock.release()
        lock.release()

    print(f"in work{n} g_num is : {g_num}\n")


if __name__ == '__main__':
    threads = []
    for i in range(10):
        threads.append(threading.Thread(target=work, args=(i,)))

    for thread in threads:
        thread.start()

    for thread in threads:
        thread.join()
    print(f"in main g_num is : {g_num}")

可以看到再同一个线程中释放锁之前,又要获取锁,就会形成嵌套锁,导致程序进入死循环。

  1. 多个资源互相等待形成的思索代码如下:
import threading
import time

g_num = 0
lock1 = threading.Lock()
lock2 = threading.Lock()


def work1(n):
    global g_num
    for i in range(1000000):
        # lock1上锁,并等待1s,等work2把lock2锁上
        print("lock1 上锁")
        lock1.acquire()
        time.sleep(1)
        # 这里会阻塞,因为work2已经把lock上锁
        lock2.acquire()
        g_num += 1
        lock2.release()
        lock1.release()

    print(f"in work{n} g_num is : {g_num}\n")

def work2(n):
    global g_num
    for i in range(1000000):
        # lock2上锁,并等待1s,等work1把lock1锁上
        print("lock2 上锁")
        lock2.acquire()
        time.sleep(1)
        # 这里会阻塞,因为work1已经把lock1锁上了
        lock1.acquire()
        g_num += 1
        lock1.release()
        lock2.release()
    print(f"in work{n} g_num is : {g_num}\n")


if __name__ == '__main__':
    t1 = threading.Thread(target=work1, args=('1',))
    t2 = threading.Thread(target=work2, args=('2',))
    t1.start()
    t2.start()
    t1.join()
    t2.join()
    print(f"in main g_num is : {g_num}")

上述代码运行结果:

lock1 上锁
lock2 上锁

可以看出,程序一开始 work1 先把 lock1 锁上,并在等待1秒钟后,想要继续把 lock2 上锁。而就在 work1 等待的那一秒钟里,work2 把 lock2 上锁了,并且也等着1秒钟后上锁 lock1。因此出现了“死锁”:work1 握着 lock1 对 work2 喊道:你把 lock2 先给我;而work2 攥着 lock2 对work1喊道:你先把 lock1 给我。两人谁都不肯先放手,于是乎,deap loop。

3.7 递归锁

为了解决互斥锁嵌套引起的死锁问题,我们可以使用Python 的递归锁,即 threading.RLock。RLock 内部维护着一个 Lock 和一个 counter 变量,counter 记录了 acquire 的次数,从而使得资源可以被多次 acquire。直到一个线程所有的 acquire 都被 release,其他的线程才能获得资源。
递归锁与互斥锁的不同之处在于,我们可以同时调用多次 lock.acquire() 对互斥资源进行加锁,而互斥锁却不可以,如果互斥锁多次进行加锁,则会导致死锁。
注意,如果是由于多个资源互相等待对方释放锁而引起的死锁,递归锁是无法解决的,只能通过程序设计的方法避免。
下面,我们改造3.6.1中由于嵌套而形成死锁的代码:

import threading
import time

g_num = 0
lock = threading.RLock()



def work(n):
    global g_num
    for i in range(1000000):
        lock.acquire()
        lock.acquire()
        g_num += 1
        lock.release()
        lock.release()

    print(f"in work{n} g_num is : {g_num}\n")


if __name__ == '__main__':
    threads = []
    for i in range(10):
        threads.append(threading.Thread(target=work, args=(i,)))

    for thread in threads:
        thread.start()

    for thread in threads:
        thread.join()
    print(f"in main g_num is : {g_num}")

可以看到,我们只是将原来的 lock = threading.Lock() 换成 lock = threading.RLock(),便解决了问题。

3.8 信号量


5 多线程实战

5.1 初探多线程编程

我们写一个简单的爬虫程序,分别用单线程和多线程的形式来实现,让大家直观感受一下多线程带来的效率的提升。

  1. 首先,我们编写 blog_spider.py 文件,来简单爬取某网站新闻页
import requests
from bs4 import BeautifulSoup

urls = [f"https://news.cnblogs.com/n/page/{n}/" for n in range(1, 51)]


def craw(url):
    res = requests.get(url)
    return res.text

def parse(html):
    soup = BeautifulSoup(html, "html.parser")
    titles = soup.find_all("h2", class_="news_entry")
    return [title.get_text() for title in titles]

if __name__ == "__main__":
    for url in urls:
        print(url)
        html = craw(url)
        parse(html)
  1. 其次,我们编写 threading-craw01.py 文件,在该文件中分别用单线程和多线程形式对目标地址进行爬取,并利用分别计算其运行时间
import threading
import time
import MyThreading.blog_spider

def single_thread():
    print("single_thread begin")
    for url in MyThreading.blog_spider.urls:
        MyThreading.blog_spider.craw(url)
    print("single_thread end")

def multi_thread():
    print("multi_thread begin")
    threads = []
    for url in MyThreading.blog_spider.urls:
        threads.append(threading.Thread(target=MyThreading.blog_spider.craw, args=(url,)))

    for thread in threads:
        thread.start()

    for thread in threads:
        thread.join()
    print("multi_thread end")

if __name__ == "__main__":
    start = time.time()
    single_thread()
    end = time.time()
    print("single thread cost:", end - start, "seconds")

    start = time.time()
    multi_thread()
    end = time.time()
    print("multi thread cost:", end - start, "seconds")

最后运行结果如下:

single_thread begin
single_thread end
single thread cost: 5.048862934112549 seconds
multi_thread begin
multi_thread end
multi thread cost: 0.6458339691162109 seconds

从最终的运行结果可以看出,使用多线程后,运行效率提高了近10倍!

99 GIL(Global Interpreter Lock)全局解释器锁

参考

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值