您能解释为什么多个线程需要在单核CPU上锁定吗?


18

假设这些线程在单核CPU中运行。作为CPU,一个周期内只能运行一条指令。就是说,甚至以为他们共享cpu资源。但计算机确保一次指令一次。那么对于多线程来说,锁是不必要的吗?


因为软件事务内存还不是主流。
dan_waterworth 2012年

@dan_waterworth因为软件事务性内存在非平凡的复杂性级别上严重失败,所以您的意思是?;)
梅森·惠勒

我敢打赌Rich Hickey不同意这一点。
罗伯特·哈维

@MasonWheeler,而非平凡的锁定效果非常好,并且从未成为难以跟踪的细微错误的来源吗?STM在非平凡的复杂性级别上可以很好地工作,但是在存在争用时却存在问题。在这种情况下,像这样的东西(STM的限制性更强)会更好。顺便说一句,随着标题的更改,我花了一段时间才弄清楚为什么我像我一样发表评论。
dan_waterworth

Answers:


32

最好用一个例子来说明。

假设我们有一个简单的任务,我们希望并行执行多次,并且希望全局跟踪任务执行的次数,例如,对网页的点击进行计数。

当每个线程到达计数递增点时,其执行将如下所示:

  1. 从内存中读取命中数到处理器寄存器中
  2. 增加该数字。
  3. 将该号码写回内存

请记住,每个线程都可以在此过程中的任何时候暂停。因此,如果线程A执行步骤1,然后被挂起,接着线程B执行所有三个步骤,则线程A恢复时,其寄存器的命中数将错误:它将恢复其寄存器,将愉快地增加旧数的点击数,并存储该递增的数字。

另外,在线程A挂起期间,可能有任何数量的其他线程运行,因此末尾线程A写入的计数可能远低于正确的计数。

因此,必须确保如果线程执行步骤1,则必须在允许任何其他线程执行步骤1之前执行步骤3,这可以由所有等待开始单个锁的线程在开始此过程之前完成。 ,并且仅在过程完成后才释放锁定,以使该“关键部分”代码不会被错误地交错,从而导致错误的计数。

但是,如果操作是原子的怎么办?

是的,在神奇的独角兽和彩虹之地,增量操作是原子的,因此上面的示例不需要锁定。

但是,重要的是要意识到,我们在神奇的独角兽和彩虹世界中只花了很少的时间。在几乎每种编程语言中,增量操作都分为上述三个步骤。这是因为,即使处理器支持原子增量操作,该操作也要昂贵得多:它必须从内存中读取,修改数字并将其写回到内存中……通常,原子增量操作是一种可能失败,这意味着上面的简单序列必须替换为循环(如下所示)。

因为即使在多线程代码中,许多变量也保持在单个线程本地,所以如果程序假设每个变量在单个线程本地,则程序效率会大大提高,并让程序员负责保护线程之间的共享状态。尤其是考虑到原子操作通常不足以解决线程问题,我们将在后面介绍。

易变变量

如果我们想避免针对此特定问题的锁定,我们首先必须意识到,第一个示例中描述的步骤实际上并不是现代编译代码中发生的事情。因为编译器假定只有一个线程正在修改变量,所以每个线程将保留其自己的变量的缓存副本,直到需要处理器寄存器进行其他操作为止。只要它具有缓存的副本,就假定它不需要返回内存并再次读取它(这会很昂贵)。只要将变量保存在寄存器中,它们也不会将其写回内存。

我们可以通过将变量标记为volatile来回到在第一个示例中给出的情况(具有上面确定的所有相同的线程问题),这告诉编译器该变量正在被其他人修改,因此必须从中读取或在访问或修改时将其写入内存。

因此,标记为volatile的变量不会使我们进入原子增量操作的领域,只会使我们与我们已经认为的接近。

使增量成为原子

一旦使用了volatile变量,我们就可以通过使用大多数现代CPU支持的低级条件设置操作(通常称为compare和set或compare和swap)来使增量操作成为原子操作。例如,在Java的AtomicInteger类中采用了这种方法:

197       /**
198        * Atomically increments by one the current value.
199        *
200        * @return the updated value
201        */
202       public final int incrementAndGet() {
203           for (;;) {
204               int current = get();
205               int next = current + 1;
206               if (compareAndSet(current, next))
207                   return next;
208           }
209       }

上面的循环重复执行以下步骤,直到步骤3成功:

  1. 直接从内存中读取易失变量的值。
  2. 增加价值。
  3. 仅当主存储器中的当前值与我们最初读取的值相同时,才使用特殊的原子操作来更改(主存储器中的)值。

如果第3步失败(因为在第1步之后值被另一个线程更改),它将再次直接从主内存中读取变量,然后重试。

尽管比较交换操作很昂贵,但在这种情况下,它比使用锁定要好一些,因为如果在步骤1之后挂起了线程,则到达步骤1的其他线程不必阻塞并等待第一个线程,可以防止代价高昂的上下文切换。当第一个线程恢复时,它将在第一次尝试写入变量时失败,但是将能够通过重新读取变量来继续执行,这又可能比需要锁定的上下文切换便宜。

因此,我们可以通过比较和交换来获得原子增量(或对单个变量进行的其他操作)的领域,而无需使用实际的锁定。

那么什么时候严格需要锁定呢?

如果您需要在一个原子操作中修改多个变量,那么锁定将是必要的,您将不会为此找到特殊的处理器指令。

只要您正在处理单个变量,并且您为失败所做的一切工作做好了准备,并且不得不读取该变量并重新开始,那么“比较并交换”就足够了。

让我们考虑一个示例,其中每个线程首先将2加到变量X,然后将X乘以2。

如果X最初为1,并且运行两个线程,则我们期望结果为(((1 + 2)* 2)+ 2)* 2 = 16。

但是,如果线程交织,即使所有操作都是原子操作,我们也可以先执行两个加法运算,然后执行乘法运算,从而得到(1 + 2 + 2)* 2 * 2 = 20。

发生这种情况是因为乘法和加法不是交换运算。

因此,仅原子操作本身是不够的,我们必须使原子操作组合起来。

我们可以通过使用锁定序列化过程来做到这一点,或者可以在开始计算时使用一个局部变量存储X的值,在中间步骤中使用另一个局部变量,然后使用compare-and-swap进行操作。仅当X的当前值与X的原始值相同时才设置一个新值。如果失败,我们将不得不通过读取X并再次执行计算来重新开始。

需要进行一些权衡:随着计算时间的延长,正在挂起的线程变得更有可能被挂起,并且在恢复之前,该值将被另一个线程修改,这意味着失败的可能性变得更大,从而导致浪费处理器时间。在极端的情况下,大量的线程需要很长时间运行计算,我们可能有100个线程读取该变量并参与计算,在这种情况下,只有第一个完成的线程才能成功写入新值,其他99个仍将完成他们的计算,但是完成后发现他们无法更新该值...这时他们将各自读取值并重新开始计算。我们可能会让其余的99个线程重复同样的问题,浪费大量的处理器时间。

在这种情况下,通过锁对关键部分进行完全序列化会更好:在没有锁的情况下,有99个线程将挂起,并且我们将按到达锁点的顺序运行每个线程。

如果序列化不是很关键(例如在递增情况下),并且在更新数量失败时将丢失的计算量很小,那么使用比较交换操作可能会获得显着优势,因为该操作比锁定便宜。


但是,如果反作用力是原子的,那该怎么办呢?
pythonee 2012年

@pythonee:如果计数器增量是原子的,则可能不是。但是,在任何大小合适的多线程程序中,您将需要在共享资源上完成非基本任务。
布朗

1
除非您使用编译器内部函数使增量原子化,否则可能并非如此。
Mike Larsen 2012年

是的,如果读取/修改(增量)/写入是原子的,则对于该操作不需要锁定。DEC-10 AOSE指令(如果结果== 0,则加一并跳过)特别是原子化的,因此可以用作测试设置信号量。手册中提到它已经足够好了,因为它将需要机器连续几天的计数才能将一个36位的寄存器完全滚动。现在,但是,并非您所做的一切都会“添加一项到内存”。
约翰·R·斯特罗姆

我已经更新了答案,以解决其中的一些问题:是的,您可以使操作原子化,但是即使在支持该操作的体系结构上,默认情况下它也不是原子性的,并且在某些情况下原子性不是足够并且需要完整的序列化。锁定是我了解的用于实现完整序列化的唯一机制。
西奥多·默多克

4

考虑以下报价:

有些人遇到问题时会想:“我知道,我将使用线程”,然后两个人遇到了麻烦

您会看到,即使在任何给定时间在CPU上运行1条指令,计算机程序所包含的内容也不仅仅是原子汇编指令。因此,例如,写入控制台(或文件)意味着您必须锁定才能确保它可以正常工作。


我以为引用是正则表达式,而不是线程?
user16764

3
这句话对我来说似乎更适用于线程(由于线程问题,单词/字符打印不正确)。但是当前输出中有一个额外的“ s”,这表明代码有三个问题。
西奥多·默多克

1
它的副作用。偶尔,您可以加1加1并得到4294967295 :)
gbjbaanb 2012年

3

似乎有很多答案试图解释锁定,但是我认为OP需要的是对多任务实际的解释。

当即使在一个CPU上,有多个线程在系统上运行时,有两种主要方法来规定如何调度这些线程(即,放置在单核CPU中运行):

  • 协作多任务 -在Win9x中使用,要求每个应用程序明确放弃控制。在这种情况下,您不必担心锁定,因为只要线程A正在执行某种算法,就可以保证它永远不会被中断。
  • 抢占式多任务处理 -在大多数现代操作系统(Win2k及更高版本)中使用。这将使用时间片,即使线程仍在工作,也会中断线程。这更加健壮,因为单个线程永远无法挂起您的整个计算机,而协作多任务确实是一种可能性。另一方面,现在您需要担心锁,因为在任何给定时间,您的一个线程可能会中断(即被抢占),并且OS可能会安排另一个线程运行。使用这种行为编码多线程应用程序时,必须考虑到在每行代码(甚至每条指令)之间可能会运行不同的线程。现在,即使只有一个内核,锁定对于确保数据的一致状态也非常重要。

0

问题不在于单个操作,而在于操作执行的较大任务。

编写许多算法时都假定它们完全控制其所处的状态。使用像您描述的那样的交错的有序执行模型,操作可能会彼此任意交错,并且如果它们共享状态,则存在状态不一致的风险。

您可以将其与可能会暂时破坏不变式以执行其功能的函数进行比较。只要从外部无法观察到中间状态,他们就可以做自己想做的任何事情。

编写并发代码时,除非您具有独占访问权,否则需要确保将竞争状态视为不安全。实现互斥访问的常见方法是在同步原语上进行同步,例如持有锁。

同步原语在某些平台上往往会导致的另一件事是,它们发出内存屏障,从而确保CPU间内存的一致性。


0

除了设置'bool'之外,没有任何保证(至少在c语言中),读取或写入变量仅使用一条指令-或在读取/写入中间不会被中断


设置32位整数需要多少条指令?
DXM 2012年

1
您能否稍微扩展一下您的第一个陈述。您暗示只能以原子方式读取/写入布尔,但这没有任何意义。硬件中实际上不存在“布尔”。它通常以字节或字的形式实现,因此怎么可能只有bool这个属性呢?您是在谈论从内存中加载,更改并推回内存,还是在寄存器级别谈论?对寄存器的所有读/写操作都不会中断,但不会加载mem,然后再进行mem存储(因为仅2条指令,然后再增加1条即可更改值)。
科宾2012年

1
在超读取/多核/分支预测/多缓存的CPU中,单条指令的概念有些棘手-但该标准指出,只有“布尔”才需要在读/写过程中对上下文切换保持安全一个变量。有一个boost :: Atomic,它将互斥体包装在其他类型周围,我认为c ++ 11增加了更多的线程保证
Martin Beckett

解释the standard says that only 'bool' needs to be safe against a context switch in the middle of a read/write of a single variable应该确实添加到答案中。
狼”

0

共享内存。

这是... 线程的定义:一堆并发进程,共享内存。

如果没有共享内存,则通常将它们称为old-school-UNIX进程。
但是,在访问共享文件时,他们有时可能需要锁。

(在类UNIX的内核中,共享内存的确通常使用代表共享内存地址的伪文件描述符来实现)


0

一个CPU一次运行一条指令,但是如果有两个或更多CPU怎么办?

没错,如果您可以编写程序以利用原子指令,那么就不需要锁,这些指令的执行在给定处理器上不可中断,并且不受其他处理器的干扰。

当需要保护多条指令不受干扰并且没有等效的原子指令时,需要使用锁。

例如,将节点插入到双向链接列表中需要更新几个内存位置。在插入之前和插入之后,某些不变量保持列表的结构。但是,在插入过程中,这些不变量会被临时破坏:列表处于“正在构建”状态。

如果在不变式的同时另一个线程在列表中穿行,或者在这种状态下试图修改它,则数据结构可能会损坏,并且行为将不可预测:软件可能崩溃,或者继续执行不正确的结果。因此,在更新列表时,线程必须以某种方式同意彼此保持距离。

可以使用原子指令来操纵适当设计的列表,因此不需要锁。用于此的算法称为“无锁”。但是,请注意,原子指令实际上是锁定的一种形式。它们专门以硬件实现,并通过处理器之间的通信进行工作。它们比不是原子的类似指令更昂贵。

在缺乏原子指令奢侈性的多处理器上,必须通过简单的内存访问和轮询循环来构建相互排斥的原语。这些问题已经由Edsger Dijkstra和Leslie Lamport等人解决。


仅供参考,我已经阅读了无锁算法,仅使用一次比较和交换就可以处理双向链接列表更新。另外,我阅读了一份有关设备的白皮书,该设备在硬件上似乎比双重比较和交换(在68040中实现但未在其他68xxx处理器中实现)便宜得多。 -linked / store-conditional允许两个链接的负载和条件存储,但前提是两个存储之间发生的访问不会回滚第一个。这比双重比较存储的实现起来容易得多……
supercat

...但是在尝试管理双链表更新时会提供类似的好处。据我所知,双向链接负载还没有流行起来,但是如果有需求的话,硬件成本似乎很便宜。
超级猫
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.