searchusermenu
  • 发布文章
  • 消息中心
点赞
收藏
评论
分享

读书笔记|并发系统概述

2023-10-26 08:52:41
2
0

书籍

Foundations of Scalable Systems By Ian Gorton

并发系统概述

分布式系统包含在多个位置的许多处理节点上并行或同时执行的多个独立代码段。 因此,任何分布式系统根据定义都是并发系统。

然而,本章关注的是我们系统中单个节点上的并发行为。 通过显式编写我们的软件来同时执行多个操作,我们可以优化单个节点上的处理和资源利用率,从而提高本地和系统范围内的处理能力(we can optimize the processing and resource utilization on a single node, and hence increase our processing capacity both locally and system-wide.)。

为什么要并发?

当一个程序等待 I/O 事件时,操作系统会调度另一个程序来执行。 通过显式地构造我们的软件以具有可以并行执行的多个活动,操作系统可以调度有工作要做的任务,而其他任务则等待 I/O(By explicitly structuring our software to have multiple activities that can be executed in parallel, the operating system can schedule tasks that have work to do while others wait for I/O.)。

将软件系统构建为并发活动的主要方法是使用线程。

线程

默认情况下,每个软件进程都有一个执行线程。 这是操作系统在调度进程执行时管理的线程。 例如,在 Java 中,您指定为代码入口点的 main() 函数定义了该线程的行为。 这个单线程可以访问程序的环境和资源,例如打开的文件句柄和网络连接。 当程序调用代码中实例化的对象中的方法时,程序的运行时堆栈用于传递参数和管理变量范围。

在您的系统中,您可以使用编程语言功能来创建和执行其他线程。 每个线程都是一个独立的执行序列,并且有自己的运行时堆栈来管理本地对象创建和方法调用。 每个线程还可以访问进程的全局数据和环境。

单线程和多线程进程的比较如下所示。

线程执行顺序

系统调度程序(在 Java 中,位于 Java 虚拟机 [JVM] 中)控制线程执行的顺序。 从程序员的角度来看,执行顺序是不确定的。

一旦调度程序为某个线程分配了cpu时间片,它就可以在指定时间段后中断某个线程并调度另一个线程运行(Once the scheduler has given a thread an execution time slot on a CPU, it can interrupt the thread after a specified time period and schedule another one to run), 这种中断称为抢占。 抢占确保每个线程都有机会运行。 因此,线程独立且异步地运行直到完成,并且调度程序根据调度算法决定运行哪个线程。

线程存在的问题

并发编程的基本问题是协调多个线程的执行,以便无论它们以什么顺序执行,它们都会产生正确的答案。 鉴于线程可以不确定地启动和抢占,任何中等复杂的程序本质上都将具有无限数量的可能的执行顺序。 这些系统不容易测试。

所有并发程序都需要避免两个基本问题。

Race Conditions

线程的非确定性执行意味着组成线程的代码语句:

  • 将按照每个线程中的定义顺序执行。
  • 可以跨线程以任意顺序重叠。

不幸的是,完全独立的线程并不是大多数多线程系统的行为方式。 如果您回头参考上图,您会发现多个线程共享进程内的全局数据。

线程可以使用共享数据结构来协调它们的工作并在线程之间传达状态。 例如,我们可能有处理来自 Web 客户端的请求的线程,每个请求一个线程。 我们还希望保留每天处理的请求总数。 当线程完成请求时,它会增加一个全局 RequestCounter 对象,所有线程在每次请求后共享并更新该对象。 最终,我们知道处理了多少请求。

在 Java 中,要执行计数器递增,CPU 必须:

  • 将当前值加载到寄存器中。
  • 增加寄存器值。
  • 将结果写回原始内存位置。

这个简单的增量实际上是三个机器级操作的序列。

由于增量操作在机器级别不是原子的,因此一个线程可以将计数器值从内存加载到 CPU 寄存器中,但在将增量值写回之前,调度程序会抢占该线程并允许另一个线程启动。 该线程从内存加载计数器的旧值并写回递增的值。 最终,原始线程再次执行并写回其增量值,该值恰好与内存中已有的值相同。

当我们以这种方式丢失更新时,称为竞争条件。 每当多个线程对某些共享状态(在本例中是一个简单的计数器)进行更改时,就会出现竞争条件。 本质上,不同的线程交错可以产生不同的结果。

竞争条件是邪恶的! 幸运的是,如果采取一些预防措施,根除它们是很简单的。

关键是识别和保护关键部分。 关键部分是更新共享数据结构的代码部分,因此如果被多个线程访问,则必须以原子方式执行。 递增共享计数器的示例是临界区的一个示例(The key is to identify and protect critical sections. A critical section is a section of code that updates shared data structures and hence must be executed atomically if accessed by multiple threads. The example of incrementing a shared counter is an example of a critical section.)。

根据经验,您应该使关键部分尽可能小,以便最大限度地减少序列化代码。 这会对性能和可扩展性产生积极影响(As a rule of thumb, you should keep critical sections as small as possible so that the serialized code is minimized. This can have positive impacts on performance and hence scalability.)。

死锁

当两个或多个线程由于每个线程都在等待另一个线程释放资源而无法继续执行时,就会发生多线程中的死锁。

要解决多线程死锁,请考虑以下策略:

  • 资源排序(Resource Ordering):为每个资源分配唯一的标识符。 确保线程始终按照其标识符的升序请求资源。
  • 超时:设置线程可以持有资源的最长时间。 如果线程在这段时间内没有完成,它将释放资源。
  • 死锁检测:定期检查是否有死锁,当检测到时,停止并重新启动其中一个线程以打破死锁。
  • 嵌套锁:确保如果一个线程需要多个锁,则必须以嵌套方式获取它们并以相反的顺序释放它们。Nested Locks: Ensure that if a thread needs multiple locks, it must obtain them in a nested manner and release them in the reverse order.)。

  • Lock Timeout:引入线程获取锁的最大等待时间。 如果它在那段时间没有获得锁定,它会后退并稍后重试。
  • 单线程获取:设计一个系统,其中单个线程负责获取所有资源,然后将其分配给其他线程。

线程状态

在 Java 中,多线程系统中的线程由抢占式、基于优先级的调度程序管理。 每个线程都有一个优先级,通常默认为 5,范围从 0 到 10。线程从其父线程获取优先级。 调度程序使线程循环经历四种状态:

  1. Created:线程对象已初始化,但尚未运行 start() 方法。
  2. Runnable:准备执行的线程。 调度程序使用 FIFO 方法,线程可以处于这种状态,直到它们被阻塞或抢占。
  3. 阻塞:线程等待事件、锁或其他条件。 一旦满足条件,它们就会返回到可运行状态。
  4. 已终止:已完成执行或已停止的线程。

线程会被更高优先级的线程抢占,或者当系统定义的时间片到期时抢占。 这种机制保证了线程间CPU的公平性。 较高优先级的线程通常处理快速的事件驱动任务,而较低优先级的线程则处理后台任务,从而最大限度地提高 CPU 使用率。

线程协调

有许多问题需要具有不同角色的线程来协调其活动,例如经典的生产者-消费者问题。 生产者生成消息并通过共享 FIFO 缓冲区将消息发送给消费者。 消费者检索这些消息,处理它们,然后从缓冲区请求更多工作。

然而,缓冲区的空间有限。 当它已满时,生产者必须等待直到有空间,如果消费者比生产者更快,他们就会等待新的物品。

一种解决方案是轮询,生产者或消费者反复检查缓冲区的状态。 但这种方法(也称为忙等待)可能会占用大量资源。

更有效的方法是让生产者和消费者阻塞,直到他们可以继续。 这样,就不会浪费任何资源。 用于发出何时继续的信号:

  • 生产者在产生数据到缓冲区后,向任何等待的消费者发出信号,表明缓冲区有可用数据。
  • 消费者在取出数据后,向任何等待的生产者发出信号,告知缓冲区中有空间。

线程池

许多多线程系统需要创建和管理执行类似任务的线程集合。 例如,在生产者-消费者问题中,我们可以拥有一组生产者线程和一组消费者线程,所有线程都同时添加和删除数据item,并协调对共享缓冲区的访问。

屏障同步

屏障同步是多线程系统中的一个概念,其中线程需要在执行过程中的某个点等待,直到所有其他线程到达同一点。 这个概念可以比作家庭聚餐的场景,直到每个人都坐在餐桌旁之前,没有任何成员开始吃饭。 例如,在多线程图像处理应用程序中,图像的不同片段可能会被移交给单独的线程进行处理。 在每个线程完成其任务之前,最终图像不被视为已完全处理。 为了确保所有线程在特定点同步,采用了屏障同步。 从视觉上看,当各个线程到达此同步点时,它们就会暂停。 一旦所有线程到达此屏障,它们就会同时继续各自的进程。

Barrier synchronization is a concept in multithreaded systems where threads are required to wait at a certain point in their execution until all other threads reach that same point. This concept can be likened to a family dinner scenario, where no member starts eating until everyone is seated at the table. For instance, in a multithreaded image-processing application, different segments of an image might be handed over to separate threads for processing. The final image isn't considered fully processed until every thread completes its task. To ensure all threads synchronize at a particular point, barrier synchronization is employed. Visually, when individual threads reach this synchronization point, they pause. Once all threads arrive at this barrier, they simultaneously continue their respective processes.

总结

本章强调了理解可扩展分布式系统领域中的线程和并发性的根本重要性。 即使您不直接编写多线程代码,了解代码如何在多线程环境中运行也至关重要,特别是在线程安全方面。 此外,为了优化系统性能,必须掌握调整线程配置的影响。

虽然不同语言的编程结构可能有所不同,但核心挑战(例如避免竞争条件和死锁)保持一致。

参考

可扩展系统的基础作者:Ian Gorton(Foundations of Scalable Systems By Ian Gorton)

0条评论
0 / 1000