文件 I/O 操作会释放 Python 中的 GIL 吗?

Do file I/O operations release the GIL in Python?

根据我所读的内容 - 例如 - 我理解 I/O 操作会释放 GIL。所以,如果我必须读取本地文件系统上的大量文件,我的理解是线程执行应该加快速度。

为了对此进行测试 - 我有一个文件夹 (input),其中包含大约 100k 个文件 - 每个文件只有一行和一个随机整数。我有两个函数 - 一个“顺序”和一个“并发”,只是将所有数字相加

import glob
import concurrent.futures
ALL_FILES = glob.glob('./input/*.txt')
  
def extract_num_from_file(fname):
    #time.sleep(0.1)
    with open(fname, 'r') as f:
        file_contents = int(f.read().strip())
    return file_contents

def seq_sum_map_based():
   return sum(map(extract_num_from_file, ALL_FILES)) 

def conc_sum_map_based():
    with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
        return sum(executor.map(extract_num_from_file, ALL_FILES))

虽然这两个函数给出了相同的结果 - “并发”版本慢了大约 3-4 倍。

In [2]: %timeit ss.seq_sum_map_based()                                                                                                     
3.77 s ± 50.2 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

In [3]: %timeit ss.conc_sum_map_based()                                                                                                    
12.8 s ± 240 ms per loop (mean ± std. dev. of 7 runs, 1 loop each)

我的代码或我的理解有问题吗?

注意:以下内容仅适用于 HDD,HDD 的移动部件会影响读取吞吐量,不适用于 SDD。巨大性能差异的性质让我清楚地知道这是一个面向 HDD 的问题,因此此信息是在该假设下运行的。

问题在于,虽然线程可能并行运行,但由于只有单个读头,因此必须从硬盘驱动器顺序读取数据。然而,更糟糕的是,由于您已经并行化了 I/O 操作,底层 OS 将安排这些 I/O 任务,以便在切换到另一个线程之前仅部分处理这些文件——毕竟,即使你只有一个整数,文件头仍然需要处理——导致读取头比你的严格顺序代码更疯狂地跳来跳去。与不需要那么多跳转的简单地按顺序读取每个文件的整体相比,所有这些都会导致开销大大增加。

例如,如果您有一个线程从磁盘加载大量数据,而第二个线程对其执行一些耗时的处理,那么这就不是什么大问题,因为这将允许I/O 操作继续进行耗时的处理。您的特定场景只是一个非常非常糟糕的情况,您放弃了 GIL 瓶颈以换取极其缓慢的 I/O 瓶颈。

简而言之,您已经正确理解 I/O 操作释放了 GIL,您只是对并行文件读取得出了错误的结论。

另一个回答说得很好:

you've given up a GIL bottleneck in exchange for a horrifically slow I/O bottleneck. In short, you've understood correctly that I/O operations release the GIL, you just came to the wrong conclusion about parallelizing file reads.

我将添加线程文件读取 可以 如果您有 I/O 备用,就像您可能拥有非常快的 SSD。

我在相对较快的 SSD(三星 970 EVO 1TB;没有 activity 的辅助驱动器)上测试了这个,从大约 1000 个不同大小的文件中读取,平均 8800 个字符。在测试中,我使用更多线程获得了更好的性能......但减少 returns 踢得很快。

In [1]: len(ALL_FILES)
995
In [2]: %timeit ThreadedFileReader(ALL_FILES, n=1).join() # single threaded
61.8 ms ± 305 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [3]: %timeit ThreadedFileReader(ALL_FILES, n=2).join()
54.7 ms ± 158 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [4]: %timeit ThreadedFileReader(ALL_FILES, n=3).join()
56.1 ms ± 135 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [5]: %timeit ThreadedFileReader(ALL_FILES, n=4).join()
57.8 ms ± 131 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [6]: %timeit ThreadedFileReader(ALL_FILES, n=5).join()
58.9 ms ± 236 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

In [7]: %timeit ThreadedFileReader(ALL_FILES, n=50).join()
68.6 ms ± 378 µs per loop (mean ± std. dev. of 7 runs, 10 loops each)

因此,与顺序读取相比,您的想法原则上是合理的,但前提是您有足够的 I/O 备用。除非你有非常快的存储,否则你可能只需要一两个额外的线程。如果您的存储速度一点也不快,单线程方法可能是可行的方法。

请记住,如果您有多个线程同时读取文件,尤其是小文件,您很可能会因驱动器的随机 读取能力而成为瓶颈。相比之下,大文件的单线程方法可能会在接近驱动器的 顺序读取 功能时遇到瓶颈。根据硬件的不同,这些性能评级可能会有很大不同。

根据硬件和您正在读取的数据的特征,顺序读取性能的好处可能超过并行读取的任何潜在收益。

为了完整起见,下面是我用来测试它的代码,尽管它对答案没有特别的影响。

class ThreadedFileReader:
    def __init__(self, files, n=5):
        self.files = deque(files)
        self.threads = []
        self.results = queue.Queue()
        for _ in range(n):
            t = threading.Thread(target=self.worker)
            t.start()
            self.threads.append(t)
    def worker(self):
        while self.files:
            fname = self.files.pop()
            with open(fname, encoding='utf-8') as f:
                data = f.read()
            self.results.put(len(data))
        return
    def join(self):
        for t in self.threads:
            t.join()