54、并发编程中的同步机制解析

并发编程中的同步机制解析

1. 并发编程基础问题

在并发编程中,有许多基础概念需要理解。以下是一些常见问题及其解释:
- 协程、线程、轻量级进程和重量级进程的区别 :协程是一种用户态的轻量级线程,由程序自身控制调度;线程是操作系统能够进行运算调度的最小单位,共享进程的资源;轻量级进程是介于线程和进程之间的一种执行单元;重量级进程拥有自己独立的内存空间和系统资源。
- 准并行性 :准并行性是指在逻辑上看起来是并行执行的,但实际上可能是通过时间片轮转等方式在物理上串行执行的。
- 任务包编程模型 :任务包编程模型将一个大的任务分解为多个小的任务,这些任务可以并行执行,并且可以动态分配给不同的执行单元。
- 忙等待 :忙等待是指一个线程在等待某个条件满足时,不断地检查该条件,而不释放CPU资源。其主要替代方案是阻塞等待,即线程在等待时释放CPU资源,当条件满足时再被唤醒。
- 显式并发编程语言 :一些显式并发编程语言包括Java、C++、Python(通过多线程和异步编程库)、Go等。
- 消息传递程序不需要显式同步机制的原因 :消息传递程序通过消息的发送和接收来进行通信,消息的传递本身就保证了一定的顺序性,因此不需要显式的同步机制。
- 基于语言和基于库的并发实现的权衡 :基于语言的并发实现通常提供更高级的抽象和更好的集成性,但可能会限制语言的灵活性;基于库的并发实现则更加灵活,但需要程序员自己处理更多的细节。
- 数据并行和任务并行的区别 :数据并行是指多个线程同时处理不同的数据块,而任务并行是指多个线程同时执行不同的任务。
- 创建新线程的机制 :常见的创建新线程的机制包括:
1. 操作系统提供的线程创建接口。
2. 编程语言的线程库。
3. 线程池。
4. 异步编程模型。
5. 协程。
6. 并行计算框架。
- fork/join比co - begin更强大的原因 :fork/join可以动态地创建和管理任务,并且可以根据任务的执行情况进行负载均衡;而co - begin通常是静态地指定并发执行的任务。
- Java中的线程池 :Java中的线程池是一组预先创建的线程,用于执行任务。线程池的目的是减少线程创建和销毁的开销,提高系统的性能。
- 两级线程实现 :两级线程实现将线程分为用户级线程和内核级线程,用户级线程由用户程序管理,内核级线程由操作系统管理。
- 就绪列表 :就绪列表是一个存储就绪线程的列表,操作系统或调度器从该列表中选择线程执行。
- 基于协程的调度、抢占和并行性的渐进实现 :首先实现协程的基本调度,然后引入抢占机制,最后实现真正的并行性。

2. 同步机制的重要性

在共享内存的并发程序中,同步是主要的语义挑战。同步通常用于使某些操作具有原子性,或者延迟操作直到某个必要的前置条件满足。原子性通常通过互斥锁来实现,互斥锁确保在同一时间只有一个线程执行某个关键代码段,关键代码段通常将共享数据结构从一个一致状态转换到另一个一致状态。条件同步允许线程等待某个前置条件,通常表示为一个或多个共享变量值的谓词。

我们实现并行线程需要原子性和条件同步。对就绪列表和相关数据结构的操作的原子性确保它们始终满足一组逻辑不变量,例如列表结构良好,每个线程要么正在运行,要么恰好位于一个列表中。条件同步体现在需要运行线程的进程必须等待,直到就绪列表非空。

需要强调的是,我们通常不希望过度同步程序,因为这会消除并行性的机会,而我们通常希望为了性能最大化并行性。而且并非所有的竞争都是有害的。我们的目标是只提供足够的同步来消除“有害”的竞争,即那些可能导致程序产生错误结果的竞争。

3. 忙等待同步

忙等待条件同步在某些情况下很容易实现。如果条件可以表示为“位置X包含值Y”,那么需要等待该条件的线程可以简单地在循环中读取X,直到Y出现。对于涉及多个位置的条件,需要原子性来一起读取这些位置,但给定原子性,实现仍然是一个简单的循环。

3.1 自旋锁

自旋锁用于提供互斥。历史上有许多互斥算法,如Dekker算法、Dijkstra算法、Peterson算法等。但实际的自旋锁需要在常数时间和空间内运行,这需要比加载和存储更强大的原子指令。

  • test_and_set指令 :test_and_set指令将一个布尔变量设置为true,并返回该变量之前是否为false的指示。使用test_and_set构建自旋锁的代码如下:
type lock = Boolean := false;
procedure acquire lock(ref L : lock)
    while not test and set(L)
        while L
            –– nothing –– spin
procedure release lock(ref L : lock)
    L := false

在多核或多处理器机器上,将test_and_set嵌入循环中会导致大量的通信,这被称为争用。为了减少争用,通常使用test - and - test_and_set锁,即线程先使用普通读取操作检查锁是否可用,直到看起来锁是空闲的,然后再使用test_and_set来获取锁。

许多处理器提供比test_and_set更强大的原子指令,如compare_and_swap(CAS)。CAS指令接受三个参数:一个位置、一个期望值和一个新值。它检查指定位置是否包含期望值,如果是,则原子地将其替换为新值,并返回是否进行了更改的指示。使用CAS可以构建公平的自旋锁,并且可以在任意大的机器上良好工作。

读者 - 写者锁是互斥锁的一个重要变体,它允许多个线程同时读取同一个数据结构,但在有线程写入时,会阻止其他线程的读写操作。

3.2 屏障

数据并行算法通常被组织为一系列高级步骤或阶段,正确性通常取决于确保每个线程在进入下一阶段之前完成上一阶段。屏障用于提供这种同步。

例如,在有限元分析中,将一个物理对象(如桥梁)建模为大量的小片段,每个线程负责计算其片段上的力。在每次迭代中,线程需要在完成当前迭代后等待其他线程完成,然后再进入下一次迭代。

最简单的实现忙等待屏障的方法是使用全局共享计数器和原子fetch_and_decrement指令。计数器初始化为线程的数量,每个线程到达屏障时递减计数器。如果不是最后一个到达的线程,则线程在一个布尔标志上自旋;最后一个到达的线程翻转布尔标志,允许其他线程继续。代码如下:

shared count : integer := n
shared sense : Boolean := true
per - thread private local sense : Boolean := true
procedure central barrier()
    local sense := not local sense
    –– each thread toggles its own sense
    if fetch and decrement(count) = 1
        –– last arriving thread
        count := n
        –– reinitialize for next iteration
        sense := local sense
        –– allow other threads to proceed
    else
        repeat
            –– spin
        until sense = local sense

这种“感觉反转”屏障在大型机器上可能会导致不可接受的争用,并且实现n个线程的屏障的时间复杂度为O(n)。Java 7的Phaser类提供了更灵活的屏障支持。

4. 非阻塞算法

传统的通过锁来实现原子性的方法是在临界区的开始获取锁,在结束时释放锁,确保同一时间只有一个线程执行受保护的代码。但还有另一种实现原子性的方法,即非阻塞算法。

例如,要对共享位置进行任意更新 x := foo(x); ,可以使用锁来保护这个操作:

acquire(L)
r1 := x
r2 := foo(r1)
–– probably a multi - instruction sequence
x := r2
release(L)

也可以使用compare_and_swap(CAS)来实现无锁更新:

start:
r1 := x
r2 := foo(r1)
–– probably a multi - instruction sequence
r2 := CAS(x, r1, r2)
–– replace x if it hasn’t changed
if !r2 goto start

如果多个核心同时执行这段代码,其中一个核心保证在第一次循环时成功,其他核心将失败并再次尝试。这表明CAS是单位置原子更新的通用原语。

在并发算法理论中,“阻塞”的定义与操作系统中的不同。如果一个线程在没有其他线程的操作的情况下无法向前推进,则称该线程被“阻塞”;反之,如果在系统的每个可达状态下,任何执行该操作的线程在单独运行时(没有其他线程的进一步干扰)保证在有限步骤内完成,则称该操作是非阻塞的。锁本质上是阻塞的,而基于CAS的代码是非阻塞的。

可以从上述示例推广到设计各种无锁的并发数据结构。对这些结构的修改通常遵循以下模式:

repeat
    prepare
    CAS
    –– (or some other atomic operation)
until success
clean up

如果算法的“准备”部分读取多个位置,可能需要再次检查以确保所有值都没有改变,然后再进行CAS操作。只读操作在双重检查成功后可以直接返回。

例如,一个广泛使用的非阻塞并发队列的入队和出队操作:

// 入队操作
To add an element to the end of the queue, a thread reads the current tail pointer to find the last node in the queue, and uses a CAS to change the next pointer of that node to point to the new node instead of being null. If the CAS succeeds (no other thread has already updated the relevant next pointer), then the new node has been inserted. As cleanup, the tail pointer must be updated to point to the new node, but any thread can do this—and will, if it discovers that tail–>next is not null.

// 出队操作
In the dequeue operation, a single CAS swings the head pointer to the next node in the queue.

非阻塞算法相对于阻塞算法有几个优点:它们对页面错误和抢占具有内在的容忍性,因为一个线程在操作中途停止运行不会阻止其他线程前进;可以安全地用于信号(事件)和中断处理程序。对于一些重要的数据结构和算法,非阻塞算法可能比锁更快。但这些算法通常非常微妙和难以设计,主要用于语言级并发机制的实现和标准库包中。

5. 内存一致性

在之前的讨论中,我们隐式地依赖于硬件内存一致性。然而,仅靠一致性不足以使多处理器或多核处理器的行为符合大多数程序员的预期。当多个位置大约同时被写入时,我们还需要担心这些写入在不同核心上可见的顺序。

5.1 顺序一致性的成本

大多数程序员直观地期望共享内存是顺序一致的,即所有写入在所有核心上以相同的顺序可见,并且任何给定核心的写入按其执行顺序可见。但顺序一致性的直接实现要求硬件和编译器对操作进行序列化,这会降低效率。

例如,普通的存储指令在发生缓存未命中时可能需要数百个周期才能完成。为了避免等待,大多数处理器设计为在存储操作“后台”完成时继续执行后续指令。未在L1缓存中可见的存储操作(或在未可见存储操作之后发生的存储操作)会被保存在一个称为写缓冲区的队列中。负载操作会检查这个缓冲区,因此核心总是能看到自己之前的存储操作,顺序程序可以正确执行。

但在并发程序中,可能会出现意外的行为。例如,线程A设置一个标志 inspected 为true,然后读取位置X;线程B更新X从0到1,然后读取标志。在大多数机器上,A可能在其对 inspected 的写入仍在写缓冲区时读取X,B也可能在其对X的写入仍在写缓冲区时读取 inspected ,导致非常违反直觉的行为。

5.2 使用栅栏和同步指令强制顺序

为了避免这种时间循环,并发语言和库的实现者通常需要使用特殊的同步或内存栅栏指令。这些指令会强制硬件通常不保证的顺序,并且会抑制编译器的指令重排序。

在上述示例中,A和B都必须防止其读取操作在逻辑上更早的写入操作之前完成。通常可以通过将读取或写入操作标识为同步指令(例如在x86上使用XCHG指令)或在它们之间插入栅栏(例如在SPARC上使用membar StoreLoad)来实现。

有时候,如上述示例,使用同步或栅栏指令足以恢复直观的行为,但在其他情况下,可能需要对程序进行更重大的更改。例如,在分布式内存机器上,不同核心可能以不同的顺序看到并发写入,导致另一个时间循环。在这种情况下,可能需要在写入操作周围使用对某个公共位置的(带栅栏的)写入,以确保一个写入操作在另一个写入操作开始之前完成。最直接的方法是将写入操作封装在基于锁的临界区内。

5.3 简化语言级推理

对于单核心上运行的程序,无论底层管道和内存层次结构有多复杂,每个制造商都保证指令按程序顺序执行;对于多核或多处理器机器,制造商在某些情况下也保证一个核心上执行的指令按顺序被其他核心上的指令看到,但这些情况因机器而异。

为了解决这个问题,语言设计者定义了内存模型,捕获“正确同步”程序的概念,并为所有此类程序提供顺序一致性的错觉。内存模型区分“普通”变量访问和特殊同步访问,跨核心的操作顺序仅基于同步访问。

两个普通访问如果发生在不同线程中,引用相同位置,并且至少有一个是写入操作,则称它们冲突;如果它们冲突且没有顺序,则称它们构成数据竞争。数据竞争 - 自由程序的执行总是顺序一致的。

例如,使用 volatile 关键字可以避免数据竞争。在以下代码中:

Initially:  p : foo = null
               initialized : volatile Boolean = false
Thread A:
    p:= new foo()
    initialized := true
Thread B:
    repeat
        −− nothing −− spin
    until initialized
    x := p.a

如果 initialized 没有被声明为 volatile ,则A的写入和B的读取之间没有跨线程顺序,对 initialized p 的访问将是数据竞争。将 initialized 声明为 volatile 可以避免这些不良可能性。

同步竞争在多线程程序中很常见,是否是错误取决于应用程序;而数据竞争几乎总是程序错误。C++内存模型完全禁止数据竞争,Java则对有竞争的程序的行为进行了约束,以保持底层虚拟机的完整性。

6. 调度器实现

为了实现用户级线程,操作系统级进程必须同步对就绪列表和条件队列的访问,通常通过忙等待实现。以下是一个简单的可重入线程调度器的伪代码:

shared scheduler lock : low level lock
shared ready list : queue of thread
per - process private current thread : thread

procedure reschedule()
    –– assume that scheduler lock is already held and that timer signals are disabled
    t : thread
    loop
        t := dequeue(ready list)
        if t ̸= null
            exit
        –– else wait for a thread to become runnable
        release lock(scheduler lock)
        –– window allows another thread to access ready list (no point in reenabling
        –– signals; we’re already trying to switch to a different thread)
        acquire lock(scheduler lock)
    transfer(t)
    –– caller must release scheduler lock and reenable timer signals after we return

procedure yield()
    disable signals()
    acquire lock(scheduler lock)
    enqueue(ready list, current thread)
    reschedule()
    release lock(scheduler lock)
    reenable signals()

procedure sleep on(ref Q : queue of thread)
    –– assume that caller has already disabled timer signals and acquired
    –– scheduler lock, and will reverse these actions when we return
    enqueue(Q, current thread)
    reschedule()

代码假设一个单一的“低级”锁( scheduler lock )保护整个调度器。线程在将其上下文块保存到队列之前(例如在 yield sleep on 中)必须获取该锁,并在从 reschedule 返回后释放该锁。

如果每个进程运行在不同的核心上,一个自旋锁足以作为“低级”锁;但如果在单处理器或核心数量少于进程数量的多处理器上运行,可能需要使用“自旋 - 然后 - 让步”锁,以避免在等待锁时浪费CPU资源。

type lock = Boolean := false;
procedure acquire lock(ref L : lock)
    while not test and set(L)
        count := TIMEOUT
        while L
            count −:= 1
            if count = 0
                OS yield()
                –– relinquish core and drop priority
                count := TIMEOUT
procedure release lock(ref L : lock)
    L := false

在大型多处理器上,可以为每个条件队列和就绪列表使用单独的锁来增加并发性,但需要小心避免竞争条件。

7. 基于调度器的同步

忙等待同步的问题是它会消耗处理器周期,这些周期无法用于其他计算。忙等待同步只有在以下情况下才有意义:
1. 当前核心没有更好的事情可做。
2. 预期等待时间小于切换到其他线程并切换回来所需的时间。

为了确保在各种系统上都有可接受的性能,大多数并发编程语言采用基于调度器的同步机制,当运行的线程阻塞时,切换到另一个线程。

7.1 信号量

信号量是最常见的基于调度器的同步机制,由Dijkstra在20世纪60年代中期提出,在Algol 68中出现,至今仍被广泛使用。

信号量基本上是一个计数器,有两个相关操作:P和V。线程调用V原子地增加计数器,调用P等待直到计数器为正,然后减少计数器。通常要求信号量是公平的,即线程按开始P操作的顺序完成这些操作。

以下是基于我们的调度器操作实现的P和V操作:

type semaphore = record
    N : integer –– always non - negative
    Q : queue of threads

procedure P(ref S : semaphore)
    disable signals()
    acquire lock(scheduler lock)
    if S.N > 0
        S.N −:= 1
    else
        sleep on(S.Q)
    release lock(scheduler lock)
    reenable signals()

procedure V(ref S : semaphore)
    disable signals()
    acquire lock(scheduler lock)
    if S.Q is nonempty
        enqueue(ready list, dequeue(S.Q))
    else
        S.N +:= 1
    release lock(scheduler lock)
    reenable signals()

计数器初始化为1且P和V操作总是成对出现的信号量称为二元信号量,它用作基于调度器的互斥锁。更一般地,计数器初始化为k的信号量可用于仲裁对k个资源副本的访问。

例如,使用信号量实现的有界缓冲区问题:

shared buf : array [1..SIZE] of bdata
shared next full, next empty : integer := 1, 1
shared mutex : semaphore := 1
shared empty slots, full slots : semaphore := SIZE, 0

procedure insert(d : bdata)
    P(empty slots)
    P(mutex)
    buf[next empty] := d
    next empty := next empty mod SIZE + 1
    V(mutex)
    V(full slots)

function remove() : bdata
    P(full slots)
    P(mutex)
    d : bdata := buf[next full]
    next full := next full mod SIZE + 1
    V(mutex)
    V(empty slots)
    return d

二元信号量 mutex 保护数据结构本身, full slots empty slots 一般信号量确保操作在安全时才开始。

综上所述,并发编程中的同步机制是一个复杂而重要的主题,涉及到原子性、条件同步、内存一致性等多个方面。不同的同步机制适用于不同的场景,程序员需要根据具体情况选择合适的同步机制来确保程序的正确性和性能。

并发编程中的同步机制解析

8. 信号量的应用与特点总结

信号量在并发编程中具有广泛的应用,下面对其特点和应用场景进行总结:
| 特点 | 描述 |
| ---- | ---- |
| 操作原子性 | V 操作原子地增加计数器,P 操作在计数器为正时原子地减少计数器,保证了操作的一致性。 |
| 公平性要求 | 通常要求信号量是公平的,即线程按开始 P 操作的顺序完成操作,避免某些线程长时间等待。 |
| 类型多样 | 二元信号量可作为互斥锁,一般信号量可用于仲裁对多个资源副本的访问。 |

信号量的应用场景包括但不限于:
- 资源管理 :如在有界缓冲区问题中,通过信号量控制缓冲区的空槽和满槽数量,确保数据的正确读写。
- 线程同步 :保证线程按特定顺序执行,例如在多线程任务中,一个线程完成某个任务后通过 V 操作通知其他线程继续执行。

9. 并发编程同步机制的对比

为了更好地理解不同同步机制的特点和适用场景,下面对忙等待同步、非阻塞算法和基于调度器的同步(以信号量为例)进行对比:
| 同步机制 | 优点 | 缺点 | 适用场景 |
| ---- | ---- | ---- | ---- |
| 忙等待同步 | 实现简单,在等待时间短或无其他任务时有效。 | 消耗处理器周期,可能导致争用问题。 | 等待时间短,核心无其他任务可执行的情况。 |
| 非阻塞算法 | 对页面错误和抢占具有容忍性,可用于信号和中断处理程序,部分场景下速度快。 | 设计复杂,难以实现。 | 对并发性能要求高,需要避免阻塞的场景。 |
| 基于调度器的同步(信号量) | 避免了忙等待的资源浪费,可在不同系统上保证性能。 | 需要额外的调度开销。 | 等待时间长,系统需要高效利用资源的场景。 |

10. 并发编程同步机制的选择流程

在实际的并发编程中,选择合适的同步机制至关重要。以下是一个选择同步机制的流程图:

graph TD;
    A[开始] --> B{等待时间短?};
    B -- 是 --> C{核心无其他任务?};
    C -- 是 --> D[忙等待同步];
    C -- 否 --> E{需要避免阻塞?};
    E -- 是 --> F[非阻塞算法];
    E -- 否 --> G[基于调度器的同步];
    B -- 否 --> G;

根据这个流程,首先判断等待时间是否短,如果等待时间短且核心无其他任务,可选择忙等待同步;如果等待时间短但核心有其他任务,且需要避免阻塞,则选择非阻塞算法;如果等待时间长,则选择基于调度器的同步。

11. 并发编程中的常见错误及避免方法

在并发编程中,容易出现一些错误,下面列举常见错误及相应的避免方法:
| 错误类型 | 描述 | 避免方法 |
| ---- | ---- | ---- |
| 数据竞争 | 不同线程对同一位置进行冲突访问且无顺序,可能导致程序结果错误。 | 使用同步机制,如锁、信号量,或声明变量为特殊同步类型(如 Java 中的 volatile)。 |
| 同步竞争 | 同步操作的顺序或时机不当,可能导致程序行为不符合预期。 | 仔细设计同步逻辑,使用合适的同步原语,确保操作顺序正确。 |
| 死锁 | 多个线程相互等待对方释放资源,导致程序无法继续执行。 | 避免循环等待,按顺序获取锁,使用超时机制等。 |

12. 并发编程的未来趋势

随着硬件技术的不断发展,如多核处理器的普及和分布式系统的广泛应用,并发编程将面临更多的挑战和机遇。未来的并发编程可能会朝着以下方向发展:
- 更高级的抽象 :编程语言将提供更高级的并发抽象,减少程序员手动处理同步细节的工作量。
- 自适应同步机制 :根据系统的负载和资源情况,自动选择合适的同步机制,提高程序的性能和可扩展性。
- 分布式并发编程 :在分布式系统中实现高效的并发编程,解决数据一致性和通信延迟等问题。

13. 总结

并发编程中的同步机制是确保程序正确性和性能的关键。本文介绍了并发编程的基础概念,包括协程、线程等的区别,以及准并行性、任务包编程模型等。详细阐述了同步机制的重要性,以及忙等待同步(自旋锁、屏障)、非阻塞算法、内存一致性和调度器实现等内容。通过对比不同的同步机制,给出了选择同步机制的流程,并指出了并发编程中的常见错误及避免方法。最后,对并发编程的未来趋势进行了展望。

在实际的并发编程中,程序员需要根据具体的应用场景和需求,选择合适的同步机制,同时注意避免常见的错误,以开发出高效、稳定的并发程序。

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值