【问题标题】:Do file I/O operations release the GIL in Python?文件 I/O 操作是否会在 Python 中释放 GIL?
【发布时间】:2021-12-28 16:21:16
【问题描述】:

根据我阅读的内容 - 例如here - 我了解 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)

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

【问题讨论】:

  • 我对此不是很胜任,但我认为并发版本只是在生成线程、切换上下文、传递 GIL 等方面浪费了额外的资源。如果 CPU工作比简单的添加更密集。需要分析您的代码才能确定
  • 如果它与您提到的开销有关,我认为增加要读取的文件数量应该会对性能差异产生影响。但我没有看到 - 对于 10 个文件或 100k 个文件,并发版本仍然同样慢
  • 我怀疑磁盘 I/O 是否可以很好地并行化,至少在通常的磁盘/文件系统上是这样。最后,所有的 I/O 操作都必须排在同一个磁盘上。
  • 线程肯定可以更快,但如果你有空闲的 I/O,它只会加快速度。如果您的磁盘读取速度很慢/饱和,那么线程对您没有好处......在许多情况下,它实际上会减慢您的速度,因为随机读取比顺序读取慢得多。最好的情况是文件有一些相当大的大小,可以利用顺序读取(更多的 I/O 来玩)。在我自己的 SSD 上测试了大约 1000 个不同大小的文件(平均 8800 个字符),5 个线程的线程确实有所帮助,3 个线程甚至更快。

标签: python python-3.x multithreading concurrency gil


【解决方案1】:

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

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

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

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

【讨论】:

  • 很好的解释,虽然这是假设一个旋转的磁盘硬盘驱动器。 SSD没有读头跳来跳去的问题。简而言之,如果您有空闲的 I/O,线程可以更快。在快速 SSD 上,线程读取可能会更快。在旋转盘片上,它不太可能有帮助。
  • 与您和@sytech 所说的一致,当我确实将代码从 HDD 移动到 SSD 机器时,多线程操作的运行速度与单线程操作一样快。
  • @sytech 感谢您提出这个问题。我忘了包含一个免责声明,即答案是特定于硬件的。我很清楚这个问题是由于硬盘瓶颈造成的,但我应该在我的回答中澄清这一点。我已经相应地编辑了我的答案。
【解决方案2】:

另一个答案很好地说明了这一点:

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

我将补充一点,如果您有空闲的 I/O,读取线程文件的性能可以更高,就像在非常快的 SSD 上一样。

我在一个相对较快的 SSD(Samsung 970 EVO 1TB;没有活动的辅助驱动器)上进行了测试,从大约 1000 个不同大小的文件中读取,平均 8800 个字符。在测试中,我通过更多线程获得了更好的性能......但收益递减很快。

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()

【讨论】:

  • 在我看来,您所做的只是创建自己的多线程池实现,它以任务完成顺序而不是任务提交顺序返回其输出(这是错误还是“功能”?) ,而标准池支持两种方式。我很难理解这段代码如何比 OP 的代码更高效。
  • @Booboo 的实现并不是很重要。性能的差异来自硬件速度的差异,不一定是代码。
  • 我明白了你的意思。但我认为您还建议从 OP 使用的 ThreadPoolExecutor 类切换到您的 ThreadedFileReader 类。顺便说一句,与 OP 的问题无关,您使用的是 deque,通常会使用第二个 queue.Queue 来排队任务。诚然,这极不可能,但您认为线程不可能在 while self.file:fname = self.files.pop() 语句之间失去控制并最终因为双端队列上没有更多元素而导致异常吗?
  • @Booboo 我相信这是可能的,是的。
猜你喜欢
  • 2014-06-15
  • 2017-12-20
  • 1970-01-01
  • 2016-04-14
  • 2011-05-10
  • 1970-01-01
  • 2016-03-06
  • 2019-08-24
相关资源
最近更新 更多