MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Python 单线程与多线程执行效率对比

2021-07-052.2k 阅读

Python 单线程与多线程执行效率对比

一、Python 线程基础概念

在深入探讨单线程与多线程执行效率之前,我们先来回顾一下 Python 中线程的基本概念。

(一)线程的定义

线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一个进程可以包含多个线程,这些线程共享进程的资源,如内存空间、文件描述符等。

在 Python 中,通过 threading 模块来支持多线程编程。threading.Thread 类用于创建和管理线程对象。例如,下面是一个简单的创建并启动线程的示例代码:

import threading


def print_numbers():
    for i in range(10):
        print(i)


thread = threading.Thread(target=print_numbers)
thread.start()

在上述代码中,我们定义了一个函数 print_numbers,然后创建了一个 Thread 对象,将 print_numbers 函数作为目标函数传递给线程对象,并通过 start() 方法启动线程。

(二)全局解释器锁(GIL)

Python 中一个重要的概念是全局解释器锁(Global Interpreter Lock,简称 GIL)。GIL 是 CPython 解释器中的一个机制,它确保在任何时刻,只有一个线程能够执行 Python 字节码。

这意味着,即使在多核处理器上,Python 的多线程程序也无法真正利用多核的优势并行执行 Python 代码。对于 CPU 密集型任务,多线程可能并不能提高执行效率,反而可能因为线程切换的开销而降低效率。然而,对于 I/O 密集型任务,多线程在 Python 中还是能发挥一定作用的。

二、单线程编程

(一)单线程原理

单线程程序按照顺序依次执行代码中的每一条语句。在一个单线程的 Python 程序中,所有的操作都是线性进行的,前一个操作完成后才会执行下一个操作。

例如,假设我们有一个简单的计算任务,计算从 1 到 1000000 的整数之和:

def calculate_sum():
    total = 0
    for i in range(1, 1000001):
        total += i
    return total


result = calculate_sum()
print(result)

在这个单线程代码中,程序会从 calculate_sum 函数开始,逐行执行循环,将每个数字累加到 total 变量中,直到完成整个计算,最后返回结果并打印。

(二)单线程适用场景

  1. 简单任务:当任务逻辑简单,不需要并发处理,并且执行时间较短时,单线程足以满足需求。例如,一个简单的文件读取并进行简单文本处理的任务。假设我们有一个文本文件,每行包含一个数字,我们需要读取文件并计算所有数字的总和:
def sum_from_file():
    total = 0
    with open('numbers.txt', 'r') as file:
        for line in file:
            try:
                number = int(line.strip())
                total += number
            except ValueError:
                pass
    return total


result = sum_from_file()
print(result)
  1. 避免复杂的同步问题:在一些对数据一致性要求极高,并且不希望引入多线程同步机制复杂性的场景下,单线程是一个很好的选择。例如,某些数据库的写入操作,如果使用多线程可能会导致数据不一致,而单线程按顺序执行写入可以保证数据的准确性。

三、多线程编程

(一)多线程原理

在 Python 中使用多线程时,通过创建多个 Thread 对象,每个对象负责执行一个特定的函数。这些线程在逻辑上是同时运行的,但由于 GIL 的存在,实际上在同一时间只有一个线程在执行 Python 字节码。

多个线程共享进程的资源,如内存空间,这使得线程间的数据共享变得容易,但同时也带来了同步问题。例如,当多个线程同时访问和修改同一个变量时,可能会导致数据竞争和不一致。

下面是一个简单的多线程示例,创建两个线程分别打印不同的内容:

import threading


def print_hello():
    for _ in range(5):
        print('Hello')


def print_world():
    for _ in range(5):
        print('World')


thread1 = threading.Thread(target=print_hello)
thread2 = threading.Thread(target=print_world)

thread1.start()
thread2.start()

thread1.join()
thread2.join()

在上述代码中,我们创建了两个线程 thread1thread2,分别执行 print_helloprint_world 函数。start() 方法启动线程,join() 方法用于等待线程执行完毕。

(二)多线程同步机制

为了解决多线程中的数据竞争问题,Python 提供了多种同步机制。

  1. 锁(Lock):锁是一种最基本的同步工具。当一个线程获取到锁时,其他线程就无法获取该锁,直到锁被释放。例如,假设我们有一个共享变量 counter,多个线程要对其进行递增操作,如果不使用锁,可能会导致数据不一致:
import threading

counter = 0
lock = threading.Lock()


def increment():
    global counter
    for _ in range(100000):
        lock.acquire()
        counter += 1
        lock.release()


threads = []
for _ in range(10):
    thread = threading.Thread(target = increment)
    threads.append(thread)
    thread.start()

for thread in threads:
    thread.join()

print(counter)

在这个例子中,每个线程在对 counter 进行递增操作前,先获取锁 lock,操作完成后释放锁,这样就保证了 counter 的递增操作是线程安全的。

  1. 信号量(Semaphore):信号量可以控制同时访问某个资源的线程数量。例如,假设我们有一个资源只能同时被 3 个线程访问,我们可以使用信号量来实现:
import threading

semaphore = threading.Semaphore(3)


def access_resource():
    semaphore.acquire()
    try:
        print(threading.current_thread().name, 'is accessing the resource')
    finally:
        semaphore.release()


threads = []
for _ in range(10):
    thread = threading.Thread(target = access_resource)
    threads.append(thread)
    thread.start()

for thread in threads:
    thread.join()

在上述代码中,Semaphore(3) 表示同时最多允许 3 个线程获取信号量并访问资源。

  1. 条件变量(Condition):条件变量用于线程之间的复杂同步。它允许一个线程等待某个条件满足后再继续执行。例如,假设我们有一个生产者 - 消费者模型,生产者线程生产数据,消费者线程消费数据,消费者线程需要等待生产者线程生产出数据后才能消费:
import threading

condition = threading.Condition()
data = None


def producer():
    global data
    with condition:
        data = 'Some data'
        condition.notify()


def consumer():
    with condition:
        condition.wait()
        print('Consumed:', data)


producer_thread = threading.Thread(target = producer)
consumer_thread = threading.Thread(target = consumer)

producer_thread.start()
consumer_thread.start()

producer_thread.join()
consumer_thread.join()

在这个例子中,消费者线程调用 condition.wait() 进入等待状态,直到生产者线程调用 condition.notify() 通知条件满足,消费者线程才会继续执行。

(三)多线程适用场景

  1. I/O 密集型任务:对于涉及网络请求、文件读写等 I/O 操作的任务,由于 I/O 操作通常比较耗时,在等待 I/O 完成的过程中,线程处于阻塞状态,此时 CPU 处于空闲状态。多线程可以利用这段时间切换到其他线程执行,从而提高整体效率。

例如,我们有一个任务是从多个网站下载图片:

import threading
import requests


def download_image(url, filename):
    response = requests.get(url)
    with open(filename, 'wb') as file:
        file.write(response.content)


image_urls = [
    'http://example.com/image1.jpg',
    'http://example.com/image2.jpg',
    'http://example.com/image3.jpg'
]

threads = []
for i, url in enumerate(image_urls):
    filename = f'image_{i + 1}.jpg'
    thread = threading.Thread(target = download_image, args=(url, filename))
    threads.append(thread)
    thread.start()

for thread in threads:
    thread.join()

在这个例子中,每个线程负责下载一张图片,在等待网络请求响应的过程中,其他线程可以继续执行,从而加快了整个下载任务的完成时间。

  1. 并发处理多个独立任务:当有多个相对独立的任务需要同时执行,且这些任务不需要频繁地共享和修改数据时,多线程可以提高程序的执行效率。例如,同时处理多个用户的请求,每个请求的处理可以看作是一个独立的任务。

四、单线程与多线程执行效率对比实验

(一)CPU 密集型任务对比

  1. 单线程实现:我们先来看一个 CPU 密集型的计算任务,例如计算斐波那契数列的第 n 项。斐波那契数列的定义为:F(n) = F(n - 1) + F(n - 2),其中 F(0) = 0,F(1) = 1。
import time


def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)


start_time = time.time()
result = fibonacci(35)
end_time = time.time()
print(f'Single - thread execution time: {end_time - start_time} seconds')
  1. 多线程实现:接下来,我们尝试使用多线程来执行相同的任务。
import threading
import time


def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)


def calculate_fibonacci_in_thread(n):
    global result
    result = fibonacci(n)


start_time = time.time()
threads = []
for _ in range(4):
    thread = threading.Thread(target = calculate_fibonacci_in_thread, args=(35,))
    threads.append(thread)
    thread.start()

for thread in threads:
    thread.join()
end_time = time.time()
print(f'Multi - thread execution time: {end_time - start_time} seconds')

在这个实验中,由于 GIL 的存在,多线程执行 CPU 密集型任务时,实际上并不能真正并行执行。每个线程在执行 Python 字节码时都需要获取 GIL,这导致线程之间频繁切换,增加了额外的开销。从实验结果来看,通常多线程执行 CPU 密集型任务的时间会比单线程更长。

(二)I/O 密集型任务对比

  1. 单线程实现:我们以文件读取和处理为例,假设文件中包含大量的文本行,我们需要逐行读取并进行简单的字符串处理(例如统计每行的字符数)。
import time


def process_file():
    total_char_count = 0
    with open('large_text_file.txt', 'r') as file:
        for line in file:
            total_char_count += len(line.strip())
    return total_char_count


start_time = time.time()
result = process_file()
end_time = time.time()
print(f'Single - thread execution time: {end_time - start_time} seconds')
  1. 多线程实现:现在使用多线程来处理相同的文件处理任务。
import threading
import time


def process_file_chunk(start, end):
    total_char_count = 0
    with open('large_text_file.txt', 'r') as file:
        lines = file.readlines()[start:end]
        for line in lines:
            total_char_count += len(line.strip())
    return total_char_count


start_time = time.time()
num_threads = 4
chunk_size = 1000
threads = []
results = []
for i in range(num_threads):
    start = i * chunk_size
    end = (i + 1) * chunk_size if i < num_threads - 1 else None
    thread = threading.Thread(target = process_file_chunk, args=(start, end))
    threads.append(thread)
    thread.start()

for thread in threads:
    thread.join()

total_result = sum(results)
end_time = time.time()
print(f'Multi - thread execution time: {end_time - start_time} seconds')

在 I/O 密集型任务中,由于线程在等待 I/O 操作完成时会释放 GIL,其他线程可以利用这段时间执行。所以,多线程在 I/O 密集型任务上通常能够显著提高执行效率,从实验结果中可以看到多线程处理文件读取和处理任务的时间会比单线程短。

五、影响多线程效率的因素

(一)线程切换开销

线程切换是有开销的。每次线程切换时,操作系统需要保存当前线程的上下文(如寄存器的值、程序计数器的值等),并恢复下一个要执行线程的上下文。这个过程涉及到内存访问等操作,会消耗一定的 CPU 时间。

在 Python 中,由于 GIL 的存在,线程切换可能会更加频繁,尤其是在 CPU 密集型任务中。当一个线程获取 GIL 执行一段时间后,会被强制释放 GIL,让其他线程有机会获取 GIL 执行,这就导致了更多的线程切换开销。

(二)资源竞争

多线程共享进程的资源,如内存、文件描述符等。当多个线程同时访问和修改共享资源时,就会发生资源竞争。为了保证数据的一致性,我们需要使用同步机制,如锁。然而,同步机制的使用会增加程序的复杂性,并且会导致线程等待锁的时间增加,从而降低了整体效率。

例如,在多个线程同时对一个共享的字典进行写入操作时,如果不使用锁进行同步,可能会导致字典数据损坏。但使用锁后,线程在获取锁和等待锁的过程中会花费额外的时间。

(三)任务粒度

任务粒度指的是任务的大小或复杂度。对于细粒度的任务,即任务执行时间很短,线程切换的开销可能会占总执行时间的很大比例。在这种情况下,多线程可能无法提高效率,甚至会因为线程切换开销而降低效率。

相反,对于粗粒度的任务,即任务执行时间较长,多线程有更多的时间在释放 GIL 时让其他线程执行,从而更有可能提高整体效率。例如,在计算斐波那契数列的例子中,如果计算的项数较小,任务粒度较细,多线程的优势就不明显;而如果计算的项数很大,任务粒度较粗,多线程就有更多机会提高效率(虽然由于 GIL 存在提升有限)。

六、优化多线程性能的方法

(一)减少锁的使用

尽量减少对共享资源的访问和修改,如果必须访问共享资源,可以尝试使用其他数据结构或算法来减少锁的竞争。例如,使用队列(Queue)来代替直接共享变量。队列是线程安全的,并且在生产者 - 消费者模型中非常实用。

import threading
import queue


def producer(q):
    for i in range(10):
        q.put(i)


def consumer(q):
    while True:
        item = q.get()
        if item is None:
            break
        print(f'Consumed: {item}')
        q.task_done()


q = queue.Queue()
producer_thread = threading.Thread(target = producer, args=(q,))
consumer_thread = threading.Thread(target = consumer, args=(q,))

producer_thread.start()
consumer_thread.start()

producer_thread.join()
q.put(None)
consumer_thread.join()

在这个例子中,生产者线程将数据放入队列,消费者线程从队列中取出数据,避免了直接共享变量带来的锁竞争问题。

(二)合理划分任务粒度

根据任务的性质和特点,合理划分任务粒度。对于 CPU 密集型任务,可以尝试将大任务划分为几个较大粒度的子任务,减少线程切换开销。对于 I/O 密集型任务,由于 I/O 操作本身的特性,较小粒度的任务也能较好地利用多线程优势。

例如,在处理大规模数据计算时,可以将数据分成几个较大的数据块,每个线程处理一个数据块,这样可以减少线程切换的频率。

(三)使用多进程代替多线程(对于 CPU 密集型任务)

由于 GIL 的限制,对于 CPU 密集型任务,使用多进程可以真正利用多核处理器的优势。Python 的 multiprocessing 模块提供了多进程编程的支持。

下面是一个使用多进程计算斐波那契数列的例子:

import multiprocessing
import time


def fibonacci(n):
    if n <= 1:
        return n
    return fibonacci(n - 1) + fibonacci(n - 2)


def calculate_fibonacci_in_process(n):
    return fibonacci(n)


start_time = time.time()
pool = multiprocessing.Pool(processes = 4)
results = pool.map(calculate_fibonacci_in_process, [35] * 4)
pool.close()
pool.join()
end_time = time.time()
print(f'Multi - process execution time: {end_time - start_time} seconds')

在这个例子中,multiprocessing.Pool 创建了一个进程池,每个进程独立执行 calculate_fibonacci_in_process 函数,避免了 GIL 的限制,对于 CPU 密集型任务能够显著提高执行效率。

七、总结

通过对 Python 单线程与多线程执行效率的对比分析,我们了解到:

  • 对于 CPU 密集型任务,由于 GIL 的存在,多线程在 Python 中往往不能提高执行效率,甚至可能因为线程切换开销而降低效率。在这种情况下,单线程或者使用多进程可能是更好的选择。
  • 对于 I/O 密集型任务,多线程能够有效地利用线程在等待 I/O 操作时释放 GIL 的时间,让其他线程执行,从而提高整体效率。
  • 在实际编程中,需要根据任务的类型、特点以及对资源的需求,合理选择单线程或多线程编程模型,并注意优化多线程性能,如减少锁的使用、合理划分任务粒度等。
  • 同时,要清楚地认识到 GIL 对 Python 多线程编程的限制,在需要充分利用多核处理器性能的场景下,考虑使用多进程等其他技术。

总之,理解 Python 单线程与多线程的执行效率差异,对于编写高效的 Python 程序至关重要。通过合理的选择和优化,可以让程序在不同的场景下发挥出最佳性能。