释放多个锁而不会导致优先级倒置

时间:2016-06-16 18:38:45

标签: multithreading threadpool multicore priority-inversion

简短版本:如何从单个线程释放多个锁,而不是在中途被抢占?

我有一个旨在在N核机器上运行的程序。它由一个主线程和N个工作线程组成。每个线程(包括主线程)都有一个可以阻塞的信号量。通常,每个工作线程在递减其信号量时被阻塞,并且主线程正在运行。但是,主要的线程不时地唤醒工作线程在一定时间内完成它们的工作,然后阻塞它自己的信号量等待它们全部重新进入睡眠状态。像这样:

def main_thread(n):
    for i = 1 to n:
        worker_semaphore[i] = semaphore(0)
        spawn_thread(worker_thread, i)
    main_semaphore = semaphore(0)

    while True:
        ...do some work...
        workers_to_wake = foo()
        for i in workers_to_wake:
            worker_semaphore[i].increment() # wake up worker n
        for i in workers_to_wake:
            main_semaphore.decrement() # wait for all workers

def worker_thread(i):
    while True:
        worker_semaphore(i).decrement() # wait to be woken
        ...do some work...
        main_semaphore.increment() # report done with step

一切都很好。问题是,其中一个工作人员最终可能会在唤醒工作人员的过程中中途抢占主要线程:例如,当Windows调度程序决定提高该工作人员的优先级时,就会发生这种情况。这不会导致死锁,但效率很低,因为剩下的线程一直处于睡眠状态,直到抢占工作完成其工作。它基本上是优先级倒置,主线程等待其中一个工作线程,一些工作线程在主线程上等待。

我可能会为此找出特定于操作系统和调度程序的黑客攻击,例如在Windows下禁用优先级提升,以及摆弄线程优先级和处理器关联性,但我喜欢跨平台的东西,并且坚固而干净。那么:我如何以原子方式唤醒一堆线程?

4 个答案:

答案 0 :(得分:3)

TL; DR

如果你真的需要尽可能多地从你的工作人员中获得,只需使用事件信号量,控制块和屏障而不是你的信号量。但请注意,这是一个更脆弱的解决方案,因此您需要平衡任何可能的收益来抵御这种下行。

上下文

首先,我需要在讨论中总结更广泛的背景......

您有一个Windows图形应用程序。它具有所需的帧速率,因此您需要主线程以该速率运行,以精确的时间间隔安排所有工作人员,以便他们在刷新间隔内完成工作。这意味着您对每个线程的开始和执行时间都有非常严格的约束。此外,您的工作线程并不完全相同,因此您不能只使用单个工作队列。

问题

与任何现代操作系统一样,Windows有各种synchronization primitives。但是,这些都不直接提供一次通知多个基元的机制。通过其他操作系统,我看到了类似的模式;它们都为多个基元提供等待的方法,但没有提供触发它们的原子方式。

那么我们可以做些什么呢?您需要解决的问题是:

  1. 准确计算所有必需工人的开始时间。
  2. 刺激实际需要在下一帧中奔跑的工人。
  3. 选项

    问题1最明显的解决方案就是使用单个事件信号量,但您也可以使用读/写锁定(通过在工作人员完成并让工作人员使用读锁定后获取写锁定) 。所有其他选项不再是原子的,因此需要进一步同步以强制线程执行您想要的操作 - 比如lostleader建议您在信号量内锁定。

    但是我们想要一个最佳解决方案,尽可能减少上下文切换由于应用程序的时间紧迫,所以让我们看看是否可以使用其中任何一个来解决问题2 ...如何选择哪个工作者如果我们只有一个事件信号量或读/写锁,那么线程应该从main运行吗?

    嗯......读/写锁是一个线程将一些关键数据写入控制块并让许多其他人从中读取的好方法。为什么不只是有一个简单的布尔标志数组(每个工作线程一个)主线程更新每个帧?遗憾的是,在计时器弹出之前,您仍需要停止执行工作程序。简而言之,我们又回到信号量并再次锁定解决方案。

    但是,由于您的应用程序的性质,您可以再做一步。您可以依赖这样一个事实:您知道您的工作人员没有在您的时间之外运行切片,而是使用事件信号量作为原始形式的锁定。

    最终优化(如果您的环境支持它们)是使用屏障而不是主信号量。你知道所有n个线程都需要在你可以继续之前处于空闲状态,所以只需坚持下去。

    解决方案

    应用上述内容,您的伪代码将如下所示:

    def main_thread(n):
        main_event = event()
        for i = 1 to n:
            worker_scheduled[i] = False
            spawn_thread(worker_thread, i)
        main_barrier = barrier(n+1)
    
        while True:
            ...do some work...
            workers_to_wake = foo()
            for i in workers_to_wake:
                worker_scheduled[i] = True
            main_event.set()
            main_barrier.enter() # wait for all workers
            main_event.reset()
    
    def worker_thread(i):
        while True:
           main_event.wait()
           if worker_scheduled[i]:
                worker_scheduled[i] = False
                ...do some work...
           main_barrier.enter() # report finished for this frame.
           main_event.reset() # to catch the case that a worker is scheduled before the main thread
    

    由于没有对worker_scheduled数组进行明确监管,因此这是一个更加脆弱的解决方案。

    因此,如果我必须从CPU中挤出最后一盎司的处理,我个人只会使用它,但听起来这正是你正在寻找的。

答案 1 :(得分:1)

当唤醒算法复杂度为O(n)时,使用多个同步对象(信号量)时不可能。但是如何解决它的方法很少。

立即释放

我不确定Python是否有必要的方法(你的问题是特定于Python的吗?),但一般来说,信号量的操作都带有参数,指定数量递减/递增。因此,您只需将所有线程放在同一个信号量上并将它们全部唤醒。类似的方法是使用条件变量和notify all

事件循环

如果您仍希望能够单独控制每个线程,但又喜欢使用一对多通知的方法,请尝试使用libuv(和its Python counterpart)之类的异步I / O库。在这里,您可以创建一个单独的事件,一次唤醒所有线程,并为每个线程创建其单独的事件,然后在每个线程的事件循环中等待两个(或更多)事件对象。 另一个库是pevents,它在pthreads的条件变量之上实现WaitForMultipleObjects

代表醒来

另一种方法是用树状算法(O(log n))替换你的O(n)算法,其中每个线程只唤醒固定数量的其他线程,但委托它们唤醒其他线程。在边缘情况下,主线程只能唤醒另一个线程,这将唤醒其他所有人或启动链式反应。如果您希望以牺牲其他线程的唤醒延迟为代价来减少主线程的延迟,这将非常有用。

答案 2 :(得分:1)

读者/作家锁

我通常在POSIX系统上用于一对多关系的解决方案是读取器/写入器锁。令我惊讶的是,它们并不是完全通用的,但是大多数语言要么实现一个版本,要么至少有一个包可用于在任何存在的原语上实现它们,例如,python' s {{3 }}:

from prwlock import RWLock

def main_thread(n):
    for i = 1 to n:
        worker_semaphore[i] = semaphore(0)
        spawn_thread(worker_thread, i)
    main_lock = RWLock()

    while True:
        main_lock.acquire_write()
        ...do some work...   
        workers_to_wake = foo()
        # The above acquire could be moved as low as here,
        # depending on how independent the above processing is..            
        for i in workers_to_wake:
            worker_semaphore[i].increment() # wake up worker n

        main_lock.release()


def worker_thread(i):
    while True:
        worker_semaphore(i).decrement() # wait to be woken
        main_lock.acquire_read()
        ...do some work...
        main_lock.release() # report done with step

障碍

prwlock似乎是Python最接近的内置机制,用于保留所有线程,直到它们全部被警告,但是:

  1. 它们是一个非常不寻常的解决方案,因此它们会使您的代码/体验难以翻译成其他语言。

  2. 我不想在这种情况下使用它们,其中要唤醒的线程数不断变化。鉴于你的n听起来很小,我很想使用常量Barrier(n)并通知所有线程检查它们是否正在运行这个循环。但是:

  3. 我担心使用屏障会适得其反,因为任何线程被外部东西阻挡都会使它们全部保持起来,甚至资源依赖性提升的调度程序也可能会错过这种关系。需要所有n来达到障碍只会使情况变得更糟。

答案 3 :(得分:0)

Peter Brittain的解决方案,以及Anton关于树状唤醒的建议,引出了另一种解决方案:链式唤醒。基本上,不是主线程执行所有唤醒,它只唤醒一个线程;然后每个线程负责唤醒下一个线程。这里优雅的一点是,只有一个挂起的线程可以运行,因此线程很少会最终切换核心。实际上,即使其中一个工作线程与主线程具有亲缘关系,这也可以在严格的处理器关联性下正常工作。

我做的另一件事是使用原子计数器,工作线程在睡觉前减少;这样,只有最后一个唤醒主线程,所以主线程也没有几次被唤醒只是为了做更多的信号量等待。

workers_to_wake = []
main_semaphore = semaphore(0)
num_woken_workers = atomic_integer()

def main_thread(n):
    for i = 1 to n:
        worker_semaphore[i] = semaphore(0)
        spawn_thread(worker_thread, i)
    main_semaphore = semaphore(0)

    while True:
        ...do some work...

        workers_to_wake = foo()
        num_woken_workers.atomic_set(len(workers_to_wake)) # set completion countdown
        one_to_wake = workers_to_wake.pop()
        worker_semaphore[one_to_wake].increment() # wake the first worker
        main_semaphore.decrement() # wait for all workers

def worker_thread(i):
    while True:
        worker_semaphore[i].decrement() # wait to be woken
        if workers_to_wake.len() > 0: # more pending wakeups
            one_to_wake = workers_to_wake.pop()
            worker_semaphore[one_to_wake].increment() # wake the next worker

        ...do some work...

        if num_woken_workers.atomic_decrement() == 0: # see whether we're the last one
            main_semaphore.increment() # report all done with step