【问题标题】:Why is this python code not thread-safe?为什么这个 python 代码不是线程安全的?
【发布时间】:2017-03-02 19:38:17
【问题描述】:

我将此作为学校作业的一部分提交,标记它的人提到此部分不是线程安全的。

任务是在 python 中创建一个多线程套接字服务器,它接受一个数字并返回该数字的斐波那契值。我的方法是通过在每个线程之间共享一个字典来记忆计算。

这是代码(为了简洁起见,删除了错误处理)

from socketserver import ThreadingMixIn, TCPServer, BaseRequestHandler


class FibonacciThreadedTCPServer(ThreadingMixIn, TCPServer):
    def __init__(self, server_address):
        TCPServer.__init__(self, server_address, FibonacciThreadedTCPRequestHandler, bind_and_activate=True)
        #this dictionary will be shared between all Request handlers
        self.fib_dict = {0: 0, 1: 1, 2: 1}


class FibonacciThreadedTCPRequestHandler(BaseRequestHandler):
    def handle(self):
        data = self.request.recv(1024).strip()
        num = int(data)
        result = self.calc_fib(self.server.fib_dict, num)
        ret = bytes(str(result) + '\n', 'ascii')
        self.request.sendall(ret)


    @staticmethod
    def calc_fib(fib_dict, n):
        """
        Calculates the fibonacci value of n using a shared lookup table and a linear calculation.
        """
        length = len(fib_dict)
        while length <= n:
            fib_dict[length] = fib_dict[length - 1] + fib_dict[length - 2]
            length = len(fib_dict)
        return fib_dict[n]

我了解在 calc_fib 方法中同时发生读取和写入,通常这意味着代码不是线程安全的。但是在这种情况下,我认为有可能证明代码将始终提供可预测的结果。

读取和写入可以同时发生的事实是否足以不被认为是线程安全的?或者如果它总是返回一个可靠的结果,那么它被认为是线程安全的。

为什么我认为这段代码总能产生可靠的结果:

  1. 在字典中的任何给定索引发生写入之前,永远不会发生读取。

  2. 对任何给定索引的任何后续写入都将包含与先前写入相同的数字,因此无论何时发生读/写序列,它始终会收到相同的数据。

我已经通过在每个操作之间添加随机睡眠并同时使用数百个线程发出请求来对此进行测试,并且在我的测试期间已经返回了正确的答案。

任何想法或批评将不胜感激。谢谢。

【问题讨论】:

  • 当你问他们这个问题时,你的标记人说了什么?
  • 我们在这个课程中可以选择让在当地公司工作的专业人士匿名标记我们的作业。不幸的是,如果不通过我老师的代理人,我不能问给它做标记的人。我希望先从 StackOverflow 社区获得一些信息。

标签: python multithreading python-3.x concurrency


【解决方案1】:

在这种特殊情况下,the GIL 应该保证您的代码安全,因为:

  1. GIL(dict 特别是需要此保证,因为类实例和非本地范围)保护 CPython 内置数据结构免受实际损坏(而不仅仅是不正确的行为)通常使用dicts 进行属性/名称查找,如果没有 GIL,简单地读取值将充满危险)
  2. 您正在更新长度的缓存值,然后将其用于下一组操作,而不是在突变期间重新检查长度;这可能会导致重复工作(多个线程会看到旧长度并重复计算新值),但由于键始终设置为相同的值,因此它们是否各自独立设置并不重要
  3. 你永远不会从你的缓存中删除(如果你这样做了,缓存的长度会咬你)

所以在 CPython 中,这应该没问题。不过,我不能对其他 Python 解释器做出任何保证;如果没有 GIL,如果他们在没有内部锁定的情况下实现 dict,则完全有可能由一个线程中的写入触发的重新哈希操作可能会导致另一个线程以不一致/不可用的状态从 dict 读取。

【讨论】:

  • 感谢您抽出宝贵时间回答。我在处理任务时试图深入研究 GIL 的复杂性,以确保我的代码是安全的。我现在只是在学校的标准课程中学习并发,所以我还是有点动摇。就术语而言,您会说我是线程安全的。或者你会说我正在以非线程安全的方式进行安全操作。我认为我的作业很聪明,我明确指出 calc_fib 方法是线程安全的:P。我正在尝试决定是否值得为分数争辩
  • @M.Wallace:好吧,如果你在 Jython 中运行它,则无法保证(无论如何从 Python 规范)它不会崩溃和烧毁。 Python 语言规范没有说明这是否安全,CPython 的特定解释器说它是安全的。所以这取决于你能做出什么样的解释器保证。对于像 C++ 这样的东西,等效代码甚至不能说您可以安全地读取结构的长度(您可以读取部分写入的值);理论上,可能有一些有效的 Python 解释器可以显示部分写入的长度。
  • 简短版:如果您只是在学习,请尽量不要过于依赖 GIL 保证并正确锁定您的访问权限。尽早获得 Python 的线程保证会对您造成严重伤害,当您最终学习较低级别的语言时,这些语言会嘲笑在一个线程上读取需要反映由另一个线程写入的数据的某种顺序一致视图的想法。
  • @M.Wallace:嗯,这里的额外技巧是 GIL 意味着所有线程实际上并没有为您提供并行计算能力。只有一个线程执行字节码意味着您的线程主要用于并行化 I/O;在最初的几个请求之后,两个线程同时锁定锁的几率非常小,因为锁的所有者必须在 GIL 中更新(并且 I/O 之间的工作足够小,它们可能为整个方法保留 GIL)。因此,如果有的话,使用锁不太可能造成太大伤害。
  • @M.Wallace:是的。没有争用的锁的获取/释放成本相当低,而且 GIL 降低了争用的几率(GIL 本身可能处于争用状态),因此添加锁的增量成本可能很小。
【解决方案2】:

首先,您为什么认为字典是线程安全的?我快速搜索了 Python3 文档(我自己是一名 Python 新手),但我无法保证两个不同步的线程可以安全地更新同一个字典,而不会损坏字典内部结构并可能导致程序崩溃。

自 1980 年以来,我一直在用其他语言编写多线程代码,并且我学会了永远不要仅仅因为它在我测试时它的行为方式就相信它是线程安全的。我想查看 supposed 是线程安全的文档。否则,我会在它周围抛出一个互斥锁。

其次,您假设fib_dict[length - 1]fib_dict[length - 2] 将是有效的。我对其他编程语言的经验表明不要假设它。在其他编程语言(例如,Java)中,当线程在没有同步的情况下共享数据时,一个线程可能会看到变量更新发生的顺序与其他线程执行它们的顺序不同。例如,理论上,访问 Map 而没有同步的 Java 线程可能会在看到新值实际出现 地图之前看到 Mapsize() 增加。我会假设在 Python 中可能会发生类似的事情,直到有人向我展示了官方文档,否则。

【讨论】:

  • 通常,CPython 会做出相当大的努力来避免线程竞争破坏数据结构;除了保护引用计数之外,the GIL 还保护dict 之类的东西,因此它不会遇到可能导致程序崩溃的问题。在某些情况下,您可能会得到错误的数据(例如,mydict[foo] += 1 在多个线程中执行时可能会丢失增量),但它不应该崩溃。
  • 由于 CPython 中的 GIL,Python dict 查找和赋值(以内置类型的实例为键)是线程安全的。使用自定义类实例作为键可能不安全,因为运行纯 Python __hash__ 方法的线程可以在字节码之间暂停以让另一个线程运行。但是内置类型(如int)的__hash__ 纯粹在C 代码中计算,不能被中断。我很确定 GIL 的同步还可以防止您在第二点中提到的乱序更新(但我不太确定)。
  • 我在假设由于 GIL 的原因所有读取都是有效的(在写入之前或之后发生)。因为在我的用例中,任何写入都只是将相同的数据放入已经包含该数据的单元格中,因此不会创建竞争条件。不过,我很难找到任何官方文档来支持这个假设。我还看到 GIL 的假设是一种不好的做法,因为它实际上不是 python 的一部分,只是 CPython 解释器的一部分。如果有人能提供更多关于我所做的是否可以被认为是安全的见解,我仍然会很感激。
  • @Blckknght:不只是__hash__ BTW;冲突解决也可以调用__eq__,这也可能导致问题(尽管频率较低,因为这两个对象只有在具有完全相同的哈希并且不是同一个对象时才会调用__eq__)。也就是说,这通常是非常病态的情况。在这种情况下,即使int 在字节码中执行__hash____eq__,操作顺序也应该保护您免受任何奇怪的事情的影响。像 dict.setdefault 这样的东西在使用非内置键时存在问题,使其行为非原子,所以要记住这一点。
猜你喜欢
  • 1970-01-01
  • 2014-06-22
  • 2018-04-12
  • 2015-07-24
  • 1970-01-01
  • 1970-01-01
  • 2023-04-05
  • 1970-01-01
相关资源
最近更新 更多