多线程从入门到放弃

本文详细探讨Python多线程的创建、锁机制、通信方式及线程池的使用,对比单线程、多线程与多进程在不同场景下的性能表现。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

在前面《python多线程浅析》一文中,我比较笼统的写了关于多线程的一些东西,本文准备比较详细的介绍一下多线程,从最基础的创建多线程开始,探讨关于锁、通信机制、线程池等内容!

单线程、多线程、多进程的对比

首先,我们通过以下四个场景,来对比以下单线程、多线程、多进程处理能力的强弱!四个场景分别是CPU计算密集型、磁盘IO密集型、网络IO密集型、还有我们模拟的IO密集型。代码如下:

import requests
import time
from threading import Thread
from multiprocessing import Process

#CPU计算密集型
def count(x=1,y=1):
    c = 0
    while c < 50000000:
        c += 1
        x += 1
        y += 1

#磁盘IO密集型
def io_disk():
    with open("test.text","w") as f:
        for x in range(500000):
            f.write("iotest\n")

headers = {
    'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36'
    }

base_url = "https://www.tieba.com"

#网络IO密集型
def io_request():
    try:
        response = requests.get(url=base_url,headers=headers)
        html = response.text
        return
    except Exception as e:
        return {"error":e}

#模拟IO密集型
def io_simulation():
    time.sleep(2)

#计算前后需要花费时间
def timer(mode):
    def wrapper(func):
        def deco(*args,**kwargs):
            type = kwargs.setdefault('type',None)
            t1 = time.time()
            func(*args,**kwargs)
            t2 = time.time()
            costtime = t2 - t1
            print("{}-{}花费时间:{}S".format(mode,type,costtime))
        return deco
    return wrapper


@timer('单线程')
def single_thread(func,type=""):
    for i in range(10):
        func()

single_thread(count,type="CPU计算密集型")
single_thread(io_disk,type="磁盘IO密集型")
single_thread(io_request,type="网络IO密集型")
single_thread(io_simulation,type="模拟IO密集型")


@timer("多线程")
def multi_thread(func,type=""):
    threads = []
    for i in range(10):
        t = Thread(target=func,args=())
        threads.append(t)
        t.start()
    lt = len(threads)

    while True:
        for td in threads:
            if not td.is_alive():
                lt -= 1
        if lt <= 0:
            break

multi_thread(count, type="CPU计算密集型")
multi_thread(io_disk, type="磁盘IO密集型")
multi_thread(io_request, type="网络IO密集型")
multi_thread(io_simulation, type="模拟IO密集型")


@timer("多进程")
def multi_process(func,type=""):
    process_list = []
    for x in range(10):
        p = Process(target=func,args=())
        process_list.append(p)
        p.start()
    lt = process_list.__len__()

    while True:
        for proc in process_list:
            if not proc.is_alive():
                lt -= 1
        if lt <= 0:
            break

multi_process(count, type="CPU计算密集型")
multi_process(io_disk, type="磁盘IO密集型")
multi_process(io_request, type="网络IO密集型")
multi_process(io_simulation, type="模拟IO密集型")

单线程-CPU计算密集型花费时间:57.472092151641846S

单线程-磁盘IO密集型花费时间:8.722064971923828S

单线程-网络IO密集型花费时间:0.576024055480957S

单线程-模拟IO密集型花费时间:20.030592918395996S

************************

多线程-CPU计算密集型花费时间:59.423059940338135S

多线程-磁盘IO密集型花费时间:13.935372114181519S

多线程-网络IO密集型花费时间:1.0296094417572021S

多线程-模拟IO密集型花费时间:2.0301761627197266S

************************

多进程-CPU计算密集型花费时间:20.213501930236816S

多进程-磁盘IO密集型花费时间:3.1664438247680664S

多进程-网络IO密集型花费时间:0.0712120532989502S

多进程-模拟IO密集型花费时间:2.0086209774017334S

首先是CPU计算密集型,多线程对比于单线程没有什么优势,反而时间增加了,这里面涉及到不断的添加和释放GIL全局变量锁的问题。而对于多进程,由于多个CPU同时工作,使得效率成倍增加。

在IO密集型中,包括磁盘IO、网络IO、数据库IO等都属于同一类。计算量很小,主要是IO等待时浪费时间。理论上来说,应该多线程更快的!但是实验的结果并不是我们想要的啊,这是为什么呢?这是因为我们的测试机是多核的!对于多核状态下的多线程,某个核释放GIL后,其他核的线程都会进行竞争,但是GIL只会被一个核拿到,那么就会导致其他核上被唤醒的线程醒着持续等待,造成线程颠簸,从而导致效率更低;而在单核的条件下,每次释放GIL唤醒的那个线程都能获得GIL锁,可以无缝执行。因此对于多核的情况,还是多进程能够有效的提高效率。

而对于我们模拟的IO密集型,利用sleep来迷你等待时间,是为了体现出多线程的优势。单线程每次需要每个线程睡2秒,10个线程就是20秒,但是在多线程的情况下,它们会切换睡觉,不会造成阻塞,这个时候,就算是10个,睡的时间也就比2秒多一点而已!

多线程的创建

在python3中,python提供了内置模块threading.Thread用于创建多线程。threading.Thread一般接收两个参数:

线程函数名:要放置线程让后台执行的函数,由我们自己定义,注意不要加括号;

线程函数的参数:线程函数名所需的参数,以元组的形式传入。若不需参数,可以不指定。

#使用函数创建多线程
import time
from threading import Thread

#自定义线程函数
def test(name="Test"):
    for i in range(2):
        print("hello",name)
        time.sleep(1)

#创建线程1,不指定参数
thread1 = Thread(target=test)
thread1.start()

#创建线程2,指定参数
thread2 = Thread(target=test,args=("CHEN",))
thread2.start()

#hello Test
#hello CHEN
#hello CHEN
#hello Test
#使用类创建多线程,注意在用类创建多线程的时候,必须继承threading.Thread这个类;必须重写#run方法!
import time
from threading import Thread

class MyThread(Thread):
    def __init__(self,name="Python"):
        super().__init__()
        self.name = name

    def run(self):
        for i in range(2):
            print("hello",self.name)
            time.sleep(1)

if __name__ == "__main__":

    #创建线程1,不指定参数
    thread1 = MyThread()
    thread1.start()

    #创建线程2,指定参数
    thread2 = MyThread("CHEN")
    thread2.start()

#hello Python
#hello CHEN
#hello Python
#hello CHEN

线程中常用的方法

t = MyThread()
#启动线程
t.start()
#阻塞子线程,待子线程结束后,再往下执行
t.join()
#判断线程是否在执行状态
t.is_alive()
t.isAlive()
# 设置线程是否随主线程退出而退出,默认为False,也就是设置线程为守护线程
t.daemon = True
#设置线程名
t.name = "name"

线程中的锁

如何使用锁?

import threading

#生成锁对象,全局唯一
lock = threading.Lock()
#获取锁,未获取到会阻塞程序,知道获取到锁才会往下执行
lock.acquire()
#释放锁,这个时候别的线程就可以用了
lock.release()

注意上面的代码,锁的获取和释放必须成对出现,否则可能造成死锁。

但是我们有的时候会忘记释放啊,这个时候,上下文管理器就派上用场了。

import threading

#生成锁对象,全局唯一
lock = threading.Lock()
with lock:
    #这里写自己的代码
    pass

with语句中会自己获取锁,在执行结束后自动释放锁。

为何使用锁?

既然锁的使用这么的复杂,我们为什么还要使用锁?我们来个例子。定义两个函数,分别在两个线程中执行,这个两个函数共用一个变量n。

import threading

def job1():
    global n
    for i in range(5):
        n += 1
        print('job1',n)

def job2():
    global n
    for i in range(5):
        n += 10
        print('job2',n)

n = 0
t1 = threading.Thread(target=job1)
t2 = threading.Thread(target=job2)
t1.start()
t2.start()

输出如下:

job1 1

job1 job22 12

job2 22

job2 32

job2 42

job2 52

job1 53

job1 54

job1 55

这个结果可以看到两个程序的结果相互影响,导致输出的结果看起来很乱,不过你也可以看到job1永远在前者的基础上+1,job2在前者的基础上+10。

如果我们给程序加上锁,它又会是什么样子呢?

import threading

lock = threading.Lock()
def job1():
    global n,lock
    lock.acquire()
    for i in range(5):
        n += 1
        print('job1',n)
    lock.release()

def job2():
    global n,lock
    lock.acquire()
    for i in range(5):
        n += 10
        print('job2',n)
    lock.release()

n = 0
t1 = threading.Thread(target=job1)
t2 = threading.Thread(target=job2)
t1.start()
t2.start()

输出如下:

job1 1

job1 2

job1 3

job1 4

job1 5

job2 15

job2 25

job2 35

job2 45

job2 55

加上锁之后,job1首先得到锁,在for循环中,没有人有权限对n进行操作。只有在job1操作完成之后,job2才拿到锁,开始自己的for循环。

可重入锁(RLock)

有时候在同一线程中,我们可能多次请求同一资源,也就是获取同一把锁,俗称锁嵌套。这个时候,如果外面那一层获取了锁,这个时候内层就不可能拿到锁了,从而造成死锁。

如何解决上面的问题呢?threading模块除了提供lock锁之外,还提供可重入锁RLock,专门处理这个问题。

import threading

def main():
    n = 0
    lock = threading.RLock()
    with lock:
        for i in range(6):
            n += 1
            with lock:
                print(n)

t1 = threading.Thread(target=main)
t1.start()

防止死锁的加锁机制

死锁的情况会在很多时候出现,比如上面的嵌套获取同一把锁;或者多个线程,不按顺序同时获取多个锁。

那么怎么来解决这个问题呢?只要我们顺序获取就可以了呗,下面这个例子是通过一个辅助函数来对锁进行排序。

import threading
from contextlib import contextmanager

# Thread-local state to stored information on locks already acquired
_local = threading.local()

@contextmanager
def acquire(*locks):
    # Sort locks by object identifier
    locks = sorted(locks, key=lambda x: id(x))

    # Make sure lock order of previously acquired locks is not violated
    acquired = getattr(_local,'acquired',[])
    if acquired and max(id(lock) for lock in acquired) >= id(locks[0]):
        raise RuntimeError('Lock Order Violation')

    # Acquire all of the locks
    acquired.extend(locks)
    _local.acquired = acquired

    try:
        for lock in locks:
            lock.acquire()
        yield
    finally:
        # Release locks in reverse order of acquisition
        for lock in reversed(locks):
            lock.release()
        del acquired[-len(locks):]

x_lock = threading.Lock()
y_lock = threading.Lock()

def thread1():
    while True:
        with acquire(x_lock):
            with acquire(y_lock):
                print('thread1')

def thread2():
    while True:
        with acquire(y_lock):
            with acquire(x_lock):
                print('thread2')

t1 = threading.Thread(target=thread1)
t1.daemon = True
t1.start()

t2 = threading.Thread(target=thread2)
t2.daemon = True
t2.start()

看到没有,表面上thread_1的先获取锁x,再获取锁y,而thread_2是先获取锁y,再获取x。
但是实际上,acquire函数,已经对x,y两个锁进行了排序。所以thread_1,hread_2都是以同一顺序来获取锁的,是不是造成死锁的。

线程的通信机制

线程的通信方法大致有以下三种:事件、Condition、队列。下面我们详细的介绍一下这三种方式。

Event事件

python提供了最简单的通信机制就是Threading.Event,是通用的条件变量。多个线程可以等待某个事件的发生,在事件发生后,所有线程都会被激活。

关于Event的使用就是下面的三个函数。

event = threading.Event()

# 重置event,使得所有该event事件都处于待命状态
event.clear()

# 等待接收event的指令,决定是否阻塞程序执行
event.wait()

# 发送event指令,使所有设置该event事件的线程执行
event.set()

举个栗子!

import time
import threading


class MyThread(threading.Thread):
    def __init__(self, name, event):
        super().__init__()
        self.name = name
        self.event = event

    def run(self):
        print('Thread: {} start at {}'.format(self.name, time.ctime(time.time())))
        # 等待event.set()后,才能往下执行
        self.event.wait()
        print('Thread: {} finish at {}'.format(self.name, time.ctime(time.time())))


threads = []
event = threading.Event()

# 定义五个线程
[threads.append(MyThread(str(i), event)) for i in range(1,5)]

# 重置event,使得event.wait()起到阻塞作用
event.clear()

# 启动所有线程
[t.start() for t in threads]

print('等待5s...')
time.sleep(5)

print('唤醒所有线程...')
event.set()

执行结果如下!

Thread: 1 start at Mon Jul  1 19:01:28 2019

Thread: 2 start at Mon Jul  1 19:01:28 2019

Thread: 3 start at Mon Jul  1 19:01:28 2019

Thread: 4 start at Mon Jul  1 19:01:28 2019

等待5s...

唤醒所有线程...

Thread: 1 finish at Mon Jul  1 19:01:33 2019

Thread: 4 finish at Mon Jul  1 19:01:33 2019

Thread: 2 finish at Mon Jul  1 19:01:33 2019

Thread: 3 finish at Mon Jul  1 19:01:33 2019

可见在所有线程都启动(start())后,并不会执行完,而是都在self.event.wait()止住了,需要我们通过event.set()来给所有线程发送执行指令才能往下执行。

Condition

Condition和Event 是类似的,并没有多大区别。同样,Condition也只需要掌握几个函数即可。

cond = threading.Condition()

# 类似lock.acquire()
cond.acquire()

# 类似lock.release()
cond.release()

# 等待指定触发,同时会释放对锁的获取,直到被notify才重新占有琐。
cond.wait()

# 发送指定,触发执行
cond.notify()

举个网上一个比较趣的捉迷藏的例子来看看。

import threading, time

class Hider(threading.Thread):
    def __init__(self, cond, name):
        super(Hider, self).__init__()
        self.cond = cond
        self.name = name

    def run(self):
        time.sleep(1)  #确保先运行Seeker中的方法
        self.cond.acquire()

        print(self.name + ': 我已经把眼睛蒙上了')
        self.cond.notify()
        self.cond.wait()
        print(self.name + ': 我找到你了哦 ~_~')
        self.cond.notify() 

        self.cond.release()
        print(self.name + ': 我赢了')

class Seeker(threading.Thread):
    def __init__(self, cond, name):
        super(Seeker, self).__init__()
        self.cond = cond
        self.name = name

    def run(self):
        self.cond.acquire()
        self.cond.wait()
        print(self.name + ': 我已经藏好了,你快来找我吧')
        self.cond.notify()
        self.cond.wait()
        self.cond.release()
        print(self.name + ': 被你找到了,哎~~~')

cond = threading.Condition()
seeker = Seeker(cond, 'seeker')
hider = Hider(cond, 'hider')
seeker.start()
hider.start()

通过cond来通信,阻塞自己,并使对方执行。从而,达到有顺序的执行。结果如下。

hider:   我已经把眼睛蒙上了

seeker:  我已经藏好了,你快来找我吧

hider:   我找到你了 ~_~

hider:   我赢了

seeker:  被你找到了,哎~~~

Queue队列

从一个线程向另一个线程发送数据最安全的方式可能就是使用 queue 库中的队列了。创建一个被多个线程共享的 Queue 对象,这些线程通过使用put() 和 get() 操作来向队列中添加或者删除元素。

同样,对于Queue,我们也只需要掌握几个函数即可。

from queue import Queue
# maxsize默认为0,不受限
# 一旦>0,而消息数又达到限制,q.put()也将阻塞
q = Queue(maxsize=0)
# 阻塞程序,等待队列消息。
q.get()
# 获取消息,设置超时时间
q.get(timeout=5.0)
# 发送消息
q.put()
# 等待所有的消息都被消费完
q.join()
# 以下三个方法,知道就好,代码中不要使用
# 查询当前队列的消息个数
q.qsize()
# 队列消息是否都被消费完,True/False
q.empty()
# 检测队列里消息是否已满
q.full()

函数会比之前的多一些,同时也从另一方面说明了其功能更加丰富。我们来举个老师点名的例子。

from queue import Queue
from threading import Thread
import time
class Student(Thread):
    def __init__(self, name, queue):
        super().__init__()
        self.name = name
        self.queue = queue

    def run(self):
        while True:
            # 阻塞程序,时刻监听老师,接收消息
            msg = self.queue.get()
            # 一旦发现点到自己名字,就赶紧答到
            if msg == self.name:
                print("{}:到!".format(self.name))
class Teacher:
    def __init__(self, queue):
        self.queue=queue

    def call(self, student_name):
        print("老师:{}来了没?".format(student_name))
        # 发送消息,要点谁的名
        self.queue.put(student_name)
queue = Queue()
teacher = Teacher(queue=queue)
s1 = Student(name="小明", queue=queue)
s2 = Student(name="小亮", queue=queue)
s1.start()
s2.start()

print('开始点名~')
teacher.call('小明')
time.sleep(1)
teacher.call('小亮')

运行结果如下。

开始点名~

老师:小明来了没?

小明:到!

老师:小亮来了没?

小亮:到!

通过学习上面三种通信方法,我们很容易就能发现Event 和 Condition 是threading模块原生提供的模块,原理简单,功能单一,它能发送 True 和 False 的指令,所以只能适用于某些简单的场景中。

而Queue则是比较高级的模块,它可能发送任何类型的消息,包括字符串、字典等。其内部实现其实也引用了Condition模块(譬如put和get函数的阻塞),正是其对Condition进行了功能扩展,所以功能更加丰富,更能满足实际应用。

消息队列除了上面说的之外,还有另外两个类,分别是queue.LifoQueue和queue.PriorityQueue。这三个类的区别如下:

queue.Queue:先进先出队列
queue.LifoQueue:后进先出队列
queue.PriorityQueue:优先级队列

threading.local类

为什么单独列出这个类呢?主要是有的时候我们需要将同一class对象创建出的同一实例,但是有多个线程,这个时候要隔离不同线程之间的数据呢?这个时候就要用到threading.local这个类了。

下面这个例子是从不用的页面获取数据,我们来计算这个页面的数据量。

import threading
from functools import partial
from socket import socket, AF_INET, SOCK_STREAM

class LazyConnection:
    def __init__(self, address, family=AF_INET, type=SOCK_STREAM):
        self.address = address
        self.family = AF_INET
        self.type = SOCK_STREAM
        self.local = threading.local()

    def __enter__(self):
        if hasattr(self.local, 'sock'):
            raise RuntimeError('Already connected')
        # 把socket连接存入local中
        self.local.sock = socket(self.family, self.type)
        self.local.sock.connect(self.address)
        return self.local.sock

    def __exit__(self, exc_ty, exc_val, tb):
        self.local.sock.close()
        del self.local.sock

def spider(conn, website):
    with conn as s:
        header = 'GET / HTTP/1.1\r\nHost: {}\r\nConnection: close\r\n\r\n'.format(website)
        s.send(header.encode("utf-8"))
        resp = b''.join(iter(partial(s.recv, 100000), b''))
    print('Got {} bytes'.format(len(resp)))

if __name__ == '__main__':
    # 建立一个TCP连接
    conn = LazyConnection(('www.sina.com.cn', 80))

    # 爬取两个页面
    t1 = threading.Thread(target=spider, args=(conn,"news.sina.com.cn"))
    t2 = threading.Thread(target=spider, args=(conn,"blog.sina.com.cn"))
    t1.start()
    t2.start()
    t1.join()
    t2.join()

如何创建线程池

由于在切换线程的时候,需要切换上下文环境,依然会造成cpu的大量开销。为解决这个问题,线程池的概念被提出来了。预先创建好一个较为优化的数量的线程,让过来的任务立刻能够使用,就形成了线程池。

在Python3中,创建线程池是通过concurrent.futures函数库中的ThreadPoolExecutor类来实现的。

import time
import threading
from concurrent.futures import ThreadPoolExecutor


def target():
    for i in range(5):
        print('running thread-{}:{}'.format(threading.get_ident(), i))
        time.sleep(1)

#: 生成线程池最大线程为5个
pool = ThreadPoolExecutor(5)

for i in range(100):
    pool.submit(target) # 往线程中提交,并运行

除了使用上述第三方模块的方法之外,我们还可以自己结合前面所学的消息队列来自定义线程池。这里我们就使用queue来实现一个上面同样效果的例子。

import time
import threading
from queue import Queue

def target(q):
    while True:
        msg = q.get()
        for i in range(5):
            print('running thread-{}:{}'.format(threading.get_ident(), i))
            time.sleep(1)

def pool(workers,queue):
    for n in range(workers):
        t = threading.Thread(target=target, args=(queue,))
        t.daemon = True
        t.start()

queue = Queue()
# 创建一个线程池:并设置线程数为5
pool(5, queue)

for i in range(100):
    queue.put("start")

# 消息都被消费才能结束
queue.join()

总结自:https://juejin.im/user/5b08d982f265da0db3502c55/posts

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值