2-6-1-1 QNX编程入门之进程和线程(七)

阅读前言

本文以QNX系统官方的文档英文原版资料“Getting Started with QNX Neutrino: A Guide for Realtime Programmers”为参考,翻译和逐句校对后,对在QNX操作系统下进行应用程序开发及进行资源管理器编写开发等方面,进行了深度整理,旨在帮助想要了解QNX的读者及开发者可以快速阅读,而不必查看晦涩难懂的英文原文,这些文章将会作为一个或多个系列进行发布,从遵从原文的翻译,到针对某些重要概念的穿插引入,以及再到各个重要专题的梳理,大致分为这三个层次部分,分不同的文章进行发布,依据这样的原则进行组织,读者可以更好的查找和理解。


1. 进程和线程

1.4. 关于同步的更多信息

2-6-1-1 QNX编程入门之进程和线程(一)

2-6-1-1 QNX编程入门之进程和线程(二)

2-6-1-1 QNX编程入门之进程和线程(三)

2-6-1-1 QNX编程入门之进程和线程(四)

2-6-1-1 QNX编程入门之进程和线程(五)

2-6-1-1 QNX编程入门之进程和线程(六)

接前面章节内容继续。

前面我们已经看过了以下有关内容:

  • mutexes
  • semaphores
  • barriers

现在让我们通过讨论以下内容来结束对同步的讨论:

  • readers/writer locks
  • sleepon locks
  • condition variables (condvars)
  • additional OS services

紧接着后面是一个例子。

1.4.1. 读写锁

正如其名称所暗示的那样,读写锁(reader and writer lock)的用途是:资源如果没有被写入器【writer】正在使用,那么可以由多个读取器【reader】进行使用,或者如果资源当前并没有被其他写入器【writer】或读取器【reader】正在使用,那么就允许写入器【writer】进行使用。

这种情况经常发生,因此需要一种专门用于此目的的特殊类型的同步原语。

通常你会有一个由一堆线程进行共享的数据结构体。显然,一次只能有一个线程写入该数据结构体。如果有多个线程在写,那么不同线程可能会覆盖彼此的数据。为了防止这种情况发生,写线程会以排他性的方式获得“rwlock”(读写锁),这意味着它可以且只有它可以访问该数据结构体。请注意,访问的排他性是通过自愿方式严格控制的。这取决于系统设计人员,以确保接触该数据区域的所有线程都通过使用“rwlock”进行同步。

读取器的情况正好相反。由于读取数据区域是一种非破坏性操作,因此任何数量的线程都可以读取数据(即使另一个线程当前也正在读取该相同数据块)。这里隐含的一点是,当任何线程从数据区读取数据时,没有线程可以写入数据区。否则,读取线程可能会因为读取部分数据而混乱,因为可能被写入线程抢占,然后,当读取线程恢复时,继续读取数据,但是可能会读取到“更新”后的数据。这样就会导致数据不一致。

让我们看一下对rwlock所使用的函数调用。

下面列出的前两个调用,用于为rwlock初始化和销毁库内部存储区域:

int
pthread_rwlock_init (pthread_rwlock_t *lock,
                     const pthread_rwlockattr_t *attr);

int
pthread_rwlock_destroy (pthread_rwlock_t *lock);

函数pthread_rwlock_init()接受参数lock(类型为pthread_rwlock_t),并根据参数attr所指定的属性对其进行初始化。我们在示例中使用了NULL属性,这意味着“使用默认值”。有关这些属性的详细信息,请参见 QNX Neutrino C Library Reference 中 pthread_rwlockattr_init()pthread_rwlockattr_destroy()pthread_rwlockattr_getpshared(),以及pthread_rwlockattr_setpshared()的内容。

使用完rwlock后,通常会调用pthread_rwlockattr_destroy()来销毁该读写锁,从而使其变为无效状态。永远不要使用已销毁或未初始化的锁。

接下来,我们需要获取一个适当类型的锁。如上所述,基本上有两种锁模式:读取器想要“非独占”访问,而写入器想要“独占”访问。为了保持名称简单,函数以锁的用户类别【读取或读写】进行命名:

int
pthread_rwlock_rdlock (pthread_rwlock_t *lock);

int
pthread_rwlock_tryrdlock (pthread_rwlock_t *lock);

int
pthread_rwlock_wrlock (pthread_rwlock_t *lock);

int
pthread_rwlock_trywrlock (pthread_rwlock_t *lock);

这里有四个函数,而不是你可能预期的两个。你可能“预期的”函数是pthread_rwlock_rdlock()pthread_rwlock_wrlock(),分别由读取器程序和写入器程序使用。这两个函数都是阻塞调用,也就是说如果所选操作没有锁可用,线程就会阻塞。当锁在适当的模式下可用时,线程就会解除阻塞。由于线程解除了对所执行调用的阻塞,那么它现在就可以假定访问受锁保护的资源是安全的。

但是,有时候,线程不想阻塞,而是想看看它是否可以获得锁。这就是“try”版本的函数的作用。重要的是要注意,“try”版本有可能会获得锁,但如果不能获得,也不会发生阻塞,而是只返回一个错误指示。这两个函数成功获得锁返回的原因(如果可以获得的话)很简单(就是EOK)。假设一个线程想要获取用于读的锁,但又不想等待,以防当前线程不可用。线程可以通过调用pthread_rwlock_tryrdlock(),获取是否可以拥有该锁。

如果pthread_rwlock_tryrdlock()没有分配到锁的话,那么可能会发生不好的事情:另一个线程可能会抢占被告知继续执行的线程,并且此时,第二个线程(也就是前面的“另一个”线程)可能会以不兼容的方式锁定该资源。由于第一个线程实际上并没有获得锁(假设的情况),所以当第一个线程使用pthread_rwlock_rdlock()去实际获取锁时(因为它“try”函数被告知可以获取锁),但是现在它却发生了阻塞,因为在该模式下资源不再可用。所以,如果我们没有锁定它,调用了“try”版本函数的线程,仍然可能会被阻塞!

这一段文字容易引起读者的误会: 上面描述的都是假设pthread_rwlock_tryrdlock()不会实际获取到一个锁的情况,在这种假设的情况中,pthread_rwlock_tryrdlock()只会去查询或测试能不能锁定,但并不获取锁,此时会存在try函数被阻塞的弊端。作者想通过这种假设告诉读者:pthread_rwlock_tryrdlock()也会获取锁;

最后,不管锁是如何被使用的,我们都需要一些释放锁的方法:

int
pthread_rwlock_unlock (pthread_rwlock_t *lock);

线程一旦完成了它想对资源执行的任何操作后,需要通过调用pthread_rwlock_unlock()来释放锁。如果存在另一个正在请求当前锁的等待线程,并且当前锁变为可用状态后,这个线程就会被设置为READY状态。

注意,我们不能只使用互斥锁就能实现这种形式的同步。互斥锁充当单线程代理,这对于写入时的情况(每次只希望一个线程使用资源)是可行的,但是在读取时的情况下就不行了,因为互斥锁只允许有一个读取器。信号量也不能使用,因为没有办法区分这两种访问模式,虽然一个信号量可以允许有多个读取器,但如果某个写入器要获取这个信号量,就信号量而言,会与读取器获取信号量没有什么区别,现在你将面临多个读取器和一个或多个写入器的复杂局面!我们将在本章后面的“An example of synchronization”中看到这一点。

关于rwlock的使用,最后需要说明的一点是:它们不是递归的,因此不是增长的(不能重复锁定)。换句话说,你不能一次又一次地锁定你的rwlock而不先解锁。你也不能先以读取方式锁定你的rwlock再以读写方式锁定而不先解锁。这样做是为了尽可能保持rwlock是轻量级的(在大小和速度方面)。

1.4.2. 休眠锁

在多线程程序中发生的另一种常见情况是线程需要等待,直到“某些事情发生”。这个“事情”可以是任何事情!它可能是数据现在可以从设备中获得,或者传送带现在已经移动到适当的位置,或者数据已经提交到磁盘,等等。这里要引入的另一个问题是,多个线程可能需要等待给定的事件。

要做到这一点,我们要么使用条件变量(我们将在下面看到),要么使用更简单的“sleepon”【睡眠锁】。

要使用睡眠锁,实际上需要执行几个操作。让我们先看看几个函数原型,然后看看如何使用这个休眠锁。

int
pthread_sleepon_lock (void);

int
pthread_sleepon_unlock (void);

int
pthread_sleepon_broadcast (void *addr);

int
pthread_sleepon_signal (void *addr);

int
pthread_sleepon_wait (void *addr);

不要被前缀 pthread_ 给欺骗了,以为这些函数是 POSIX 函数,其实它们并不是。

如上所述,线程需要等待某些事情发生。在上面的函数列表中,最明显的选择是pthread_sleepon_wait()。但是首先,线程需要先检查是否真的需要等待。让我们举个例子。一个线程是生产者线程,它从某些硬件获取数据。另一个线程是消费者线程,它对刚刚到达的数据进行某种形式的处理。让我们先看看消费者线程:

volatile int data_ready = 0;

consumer ()
{
    while (1) {
        while (!data_ready) {
            // WAIT
        }
        // process data
    }
}

消费者处于主处理循环中(while(1));永远在执行。它要做的第一件事是查看data_ready标志。如果该标志为0,则表示没有准备好数据。因此,消费者应该等待。生产者无论何种原因而唤醒消费者,则此时消费者应该重新检查它的data_ready标志。假设这就是实际发生的情况,消费者看了看标志并发现它是1,这意味着数据现在是可用的。消费者去处理数据,然后去看看是否还有更多的工作要做,等等。

我们会遇到一个问题。消费者应该如何以与生产者同步的方式重置data_ready标志?显然,我们需要某种形式的对标志的独占访问,以便在给定时间只有一个线程在修改该标志。本例中所使用的方法是用互斥锁构建的同步,但互斥锁是隐藏在sleepon库实现中的,因此我们只能通过两个函数访问它:pthread_sleepon_lock()pthread_sleepon_unlock()。让我们来修改一下我们的消费者代码:

consumer ()
{
    while (1) {
        if (pthread_sleepon_lock () == EOK)
        {
            while (!data_ready)
            {
                // WAIT
            }
            // process data
            data_ready = 0;
            pthread_sleepon_unlock ();
        }
    }
}

现在我们已经围绕消费者的操作添加了锁和解锁。这意味着消费者现在可以在没有竞争条件的情况下可靠地测试data_ready标志,并且还可以可靠地设置该标志。

很好。那"等待"函数呢?正如我们前面所建议的,实际上是使用pthread_sleepon_wait()调用。下面是第二个while循环:

while (!data_ready) {
    pthread_sleepon_wait (&data_ready);
}

pthread_sleepon_wait()实际上执行三个不同的步骤:

  • 解锁睡眠锁库函数中的互斥锁。
  • 执行等待操作。
  • 重新锁定休眠库互斥锁。

它必须解锁和再次锁定sleeponlibrary 中的互斥锁的原因很简单,因为互斥锁的整个思想是确保data_ready变量的操作是互斥的,这意味着我们希望在测试时锁定生产者,使其无法接触data_ready变量。但是,如果我们不执行操作的解锁部分,生产者将永远无法设置该标志来告诉我们数据确实可用了!重新锁定操作纯粹是为了方便;这样的话,pthread_sleepon_wait()的用户在被唤醒时就不必关心锁的状态。

让我们切换到生产者端,看看它是如何使用sleepon library 的。下面是完整的实现:

producer ()
{
    while (1) {
        // wait for interrupt from hardware here...
        if (pthread_sleepon_lock () == EOK)
        {
            data_ready = 1;
            pthread_sleepon_signal (&data_ready);
            pthread_sleepon_unlock ();
        }
    }
}

正如你所看到的,生产者也锁定了互斥锁,这样它就可以独占访问和设置data_ready变量了。

注意:唤醒客户端的不是向data_ready写入1的行为!而是对pthread_sleepon_signal()的调用。

我们可以把消费者和生产者的状态划分为:

State

Meaning

CONDVAR

等待与睡眠相关的潜在条件变量

MUTEX

等待互斥锁

READY

能够使用或已经可以使用CPU

INTERRUPT

等待硬件的中断

让我们详细看看发生了什么:

Action

Mutex owner

Consumer state

Producer state

消费者锁定互斥锁

Consumer

READY

INTERRUPT

消费者检查data_ready标记

Consumer

READY

INTERRUPT

消费者调用pthread_sleepon_wait()

Consumer

READY

INTERRUPT

pthread_sleepon_wait()解锁互斥锁

Free

READY

INTERRUPT

pthread_sleepon_wait()阻塞

Free

CONDVAR

INTERRUPT

时间流逝

Free

CONDVAR

INTERRUPT

硬件生成了数据

Free

CONDVAR

READY

生产者锁定互斥锁

Producer

CONDVAR

READY

生产者设置data_ready标记

Producer

CONDVAR

READY

生产者调用pthread_sleepon_signal()

Producer

CONDVAR

READY

消费者被唤醒,pthread_sleepon_wait()尝试锁定互斥锁

Producer

MUTEX

READY

生产者释放互斥锁

Free

MUTEX

READY

消费者获得互斥锁

Consumer

READY

READY

消费者处理数据

Consumer

READY

READY

生产者等待更多数据

Consumer

READY

INTERRUPT

时间流逝(消费者处理数据过程中)

Consumer

READY

INTERRUPT

消费者完成数据处理,释放互斥锁

Free

READY

INTERRUPT

消费者循环回到第一步,再次锁定互斥锁

Consumer

READY

INTERRUPT

表中的最后一个表项是第一个表项的重复,我们已经走了完整的一个循环。

data_ready变量的目的是什么?它实际上有两个目的:

  • 它是消费者和生产者之间的状态标志,指示系统的状态。如果被设置为1,则表示数据可供处理;如果被设置为0,则表示没有数据可用,消费者应该被阻塞。
  • 它是“睡眠同步发生的地方”。更正式地说,data_readyaddress被用作唯一的标识符,作为休眠锁会合的对象【rendezvous object】。我们也可以使用“(void *)12345”轻易替代“&data_ready”,因为只要标识符是唯一的并且使用一致即可,sleepon library 就不会在意其他东西。实际上,使用进程中变量的地址可以保证生成进程中唯一的编号,因为,毕竟在同一个进程中没有两个变量具有相同的地址!

我们把对“pthread_sleepon_signal()pthread_sleepon_broadcast()之间的区别”的讨论推迟到下一个章节,也就是条件变量中进行讨论。


未完待续,请继续关注本专栏内容……

你可能感兴趣的:(2-6-1,QNX,编程入门,blackberry,QNX,车载系统)