为了账号安全,请及时绑定邮箱和手机立即绑定

此帖子不代表现在、以前或将来的雇主的观点。文章中的看法仅代表个人观点。

上次我们介绍了FIFO的基本概念,我当时可能有点轻率地把它们说得好像很简单了 :-). 虽然我认为这些基本概念一个五年级的小朋友都能理解,但在实际操作FIFO时,情况就不同了。基本的入队和出队操作倒是没什么问题,问题在于两个棘手的条件:_满_和,我们称之为FIFO条件。好,我们来聊聊这个……

非阻塞和阻塞

当生产者遇到满FIFO或消费者遇到空FIFO时,线程应该怎么做?可以选择“非阻塞”或“阻塞”。

非阻塞操作更简单明了:尝试该操作,如果失败就重试。线程可以在重试循环中继续尝试,直到操作成功。参考上一回的博客文章中的入队和出队伪代码函数(上一篇博客),非阻塞操作可以这样实现:

    // 非阻塞 FIFO 入队(循环直至 FIFO 未满状态)  
    while (!enqueue(fifo, obj)) {};  

    // 非阻塞 FIFO 出队(循环直至 FIFO 非空)  
    while (!dequeue(fifo, obj)) {};

为了实现阻塞,需要操作系统的支持。其思路是,如果操作不能立即完成,调用该操作的线程会被挂起,当阻塞条件解除时,线程会被唤醒,并重新尝试该操作。可以参考上次提供的伪代码来提供阻塞语义。该代码使用了条件变量,这样消费者和生产者线程就可以在阻塞条件解除时互相通知。

    struct fifo {  
        int cons_ptr, prod_ptr;  
        bool fifo_full;  
        int num_els;  
        void *elements[];  
        Lock lock;  
        Cond_var prod_cond, cons_cond;  
    }  

    void blocking_enqueue(struct fifo *fifo, void *obj)  
    {  
        LOCK(fifo->lock); // 用于多个生产者和消费者之间的锁  

        if (fifo->prod_ptr == fifo->cons_ptr) {  
            if (fifo->fifo_full) {  
                // FIFO 已满  
                // 使用条件变量阻塞当前线程,直到 FIFO 不再满  
                do {  
                    COND_WAIT(fifo->prod_cond, fifo->lock);  
                } while (fifo->fifo_full);  
            } else {  
                // FIFO 为空,唤醒消费者,因为 FIFO 现在不再为空  
                COND_SIGNAL(fifo->cons_cond);  
            }  
        }  

        fifo->elements[fifo->prod_ptr] = obj;  
        fifo->prod_ptr = (fifo->prod_ptr + 1) % fifo->num_els;  

        if (fifo->prod_ptr == fifo->cons_ptr)  
            fifo->fifo_full = true;  

        UNLOCK(fifo->lock);  
    }  

    void blocking_dequeue(struct fifo *fifo, void **obj)  
    {  
        LOCK(fifo->lock); // 用于多个消费者和生产者之间的锁  

        if (fifo->prod_ptr == fifo->cons_ptr) {  
            if (!fifo->fifo_full) {  
                // FIFO 为空  
                // 使用条件变量阻塞当前线程,直到 FIFO 不再为空  
                do {  
                    COND_WAIT(fifo->cons_cond, fifo->lock);  
                } while ((fifo->prod_ptr == fifo->cons_ptr) && !fifo->fifo_full);  
            } else {  
                // FIFO 已满,清除标志,并发送信号给生产者  
                fifo->fifo_full = false;  
                COND_SIGNAL(fifo->prod_cond);  
            }  
        }  

        *obj = fifo->elements[fifo->cons_ptr];  
        fifo->cons_ptr = (fifo->cons_ptr + 1) % fifo->num_els;  

        UNLOCK(fifo->lock);  
    }
水印

我们描述的用于解阻塞 FIFO 的算法效率不高。比如,生产者生成数据的速度超过了消费者消费的速度。当生产者被阻塞时,可能会陷入一种循环模式,例如:

  1. 生产者在FIFO满时阻塞
  2. 消费者消费一个对象并唤醒生产者
  3. 生产者被唤醒并生产一个对象到FIFO中
  4. FIFO再次满时,生产者再次回到第一步

每当生产者被唤醒时,它都会放一个对象进去,然后立刻就阻塞在FIFO上等待。唤醒一个线程在处理周期上是相当昂贵的,因此目标是在每次线程运行时尽可能地完成更多的有效工作。一种解决办法是使用生产者水印。

这个想法是,一旦FIFO满了,当队列中的对象数量降到某个特定值(即水印)之下时,才会唤醒一个生产者。例如,如果FIFO的限制是100,并且水印设置为90,那么当队列中的对象数量减少到90个或更少时,才会唤醒一个生产者。也就是说,一旦被唤醒,生产者可以添加最多10个对象后再次被阻塞,从而将唤醒线程的成本摊薄。我们的出队伪代码可以修改以支持生产者水印。

    // 在从队列中取出时,处理发送者水印的逻辑,如果队列已满且 (NUM_ENQUEUE(fifo) 小于或等于 fifo->prod_water_mark),则将 fifo->fifo_full 设置为 false 并发送 COND_SIGNAL(fifo->prod_cond) 信号。
    if (fifo->fifo_full && (NUM_ENQUEUE(fifo) <= fifo->prod_water_mark)) {  
        fifo->fifo_full = false;  
        COND_SIGNAL(fifo->prod_cond);  
    }

在 FIFO 的消费者端也可以设置一个水印,表示在唤醒消费者线程之前需要在空 FIFO 中添加多少对象。消费者水印的好处与生产者水印类似,但有一个问题:这会在对象入队和出队之间引入额外的等待时间,这会影响对象的处理。这种延迟可能会非常大,甚至无穷大,如果生产者没有入队足够多的对象来达到设定的水印。为解决这个问题,使用了一个定时器。定时器在 FIFO 变为非空时启动,并在到期时唤醒任何被阻塞的消费者线程。定时器将最大延迟限制在一个合理范围内,并将到期时间设置为在摊销和最小化延迟之间取得平衡。找到 合适的 超时时间可能很棘手,并且在许多不同的情境下都是讨论的话题,包括 中断合并

民调

当一个线程同时处理多个FIFO时,情况变得更加有趣味。为了提高效率,我们希望一次性检查多个FIFO是否可以读取或写入,以保持与原文意思的一致性。这时就可以用poll功能了!感兴趣的FIFO会被添加到poll函数的参数列表中;FIFO可以被标记为可读或可写。poll函数会返回已准备好进行读取或写入操作的FIFO,即那些标记为可读且实际上可读的FIFO,以及那些标记为可写且实际上可写的FIFO。就像处理基本的FIFO操作一样,poll函数可以是阻塞的或非阻塞的。

轮询对于互联网来说是至关重要的,但它也是较为复杂和强大的支持功能之一。想要了解更多关于软件轮询的信息,请参考 Linux epoll。在我们接下来的文章中,我们将介绍一种硬件轮询的方法。

FIFO轮询。在此示例中,一个线程正在轮询四个FIFO检查它们是否可读,同时也在轮询四个FIFO检查它们是否可写。生产者水印设置为2(淡紫色虚线),没有设置消费者水印。FIFOs #1、#3和#4将被标记为可读,因为它们不为空,FIFOs #12和#13将被标记为可写,因为它们的入队对象数量不超过水印设定的2个。

雷鸣般的大群问题 (Thundering Herd Problem)

一个具有多个消费者或多个生产者的FIFO可能会受到可怕的 thundering herd 问题的影响。假设有一个空的FIFO,有十个消费者正在FIFO上阻塞。当有一个对象入队时,应该唤醒哪一个消费者呢?如果我们把他们都唤醒了,那么只有一个消费者能够成功出队该对象,其他九个消费者仍然会看到FIFO是空的,并且马上又进入阻塞状态(这就是 thundering herd 问题)。这九个线程们经历了[虚假唤醒],非常糟糕,因为他们浪费了宝贵的处理周期。他们白白浪费了处理周期,没有任何好处。

在 Linux 中,雷鸣群问题已经得到了大部分解决或至少有所缓解。Linux 使用 epoll 只唤醒一个线程并确保操作被执行,从而解决了这个问题。Linux 的解决方案需要相当多的操作系统基础设施以及线程调度器和 FIFOs 之间的紧密集成,因此对于低级裸机环境,我们可能需要考虑其他方案。

最明显的方法是使用单生产者单消费者FIFO(没有多个生产者或消费者)。这在类似数据路径中的CPU架构中CPU之间的通信时表现良好。这种方法存在两个主要问题:1)它在扩展性上表现不佳,例如,创建一个全网状结构,其中包含_N_个节点需要O(N ²)个FIFO队列,2)这种方法仅在没有顺序要求的情况下才有效,否则对象可能会被处理成乱序状态,如下所示。

单生产者单消费者的FIFO中的排序问题。 两个线程正在处理同一流的包,采用水平并行,我们需要保持正确的顺序以在网络中传输。Thread A 先于 Thread B 入队(Thread A 在 Thread B 上游),然而,传输器可能会以相反的顺序出队这些包,导致它们错序传输。

这种排序问题可以通过使用如下的多生产者单消费者FIFO可以解决,但这就又回到了“thundering herd”问题(或“轰鸣群效应”)。这可以通过我们下一节中提到的信用管理FIFO来解决。

通过使用多生产者单消费者FIFO队列来避免由多线程操作引起的排序问题。 这与上述情况相同,只是两个线程向同一个FIFO队列中入队。依赖关系确保线程A在B线程之前入队,因此当发送器从单个FIFO队列中读取并发送数据包时,可以保证数据包在网络发送时的正确顺序。

信用管理的FIFO队列

管理多个生产者的FIFO(先进先出队列)的一种常见方法是“发送者额度”。每个生产者都会被分配一定数量的可用额度,这些额度可以用于他们各自的FIFO队列中。当一个生产者入队到FIFO时,会消耗一个额度,并减少其可用额度数一个。当消费者从FIFO中出队时,会将额度返还给该对象的生产者,然后该生产者增加其可用额度数一个。如果一个生产者没有可用的额度来入队,则需要等待,直到他们有足够的额度可以继续入队。补充的额度通过消费者发送给生产者的另一个FIFO中的消息来传达,这些消息通常通过更高层次的协议进行双向通信来传递。所有生产者可用额度的总和不超过FIFO的最大容量,因此一旦FIFO已满,所有生产者都将无法继续入队新的对象——因此避免了“轰鸣效应”问题!

信用是一个相当基本的概念,这个概念在通信中非常重要,广泛应用于各种网络传输协议,包括TCP、QUIC和InfiniBand。但也有一些不足之处。例如,一旦分配了信用给生产者,没有简单的办法让他们放弃这些信用额度。这样一来,我们可能需要设置更大的FIFO限制,这可能不是我们所期望的,因为有些生产者可能根本不会去利用这些信用额度。

信用管理FIFO的例子。左边的生产者一开始有四个可用的信用额度。每次生产者入队一个消息时,会消耗一个信用额度。当可用的信用额度降为零时,生产者就需要等待。消费者出队并处理对象,每次出队一个对象,就可以向生产者返还一个信用额度。信用额度通过发送给生产者的高层消息返还。

只是冰山一角

这种对FIFO的描述已经足够,我们可以继续讨论硬件和软件中的FIFO。但是,FIFO本身,或者在网络中称为队列,是一个专门的研究领域。我甚至可以说,网络中大约一半的问题与FIFO和队列管理有关,而且其中大部分关注点都集中在空和满的状态上!多年来已经有许多研究在通信中优化FIFO或队列的使用,但仍有许多未解问题(如果你正在寻找一些有趣的课题来研究的话,这里有很多选择 :-))!

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
手记
粉丝
3
获赞与收藏
2

关注作者,订阅最新文章

阅读免费教程

  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消