本文以QNX系统官方的文档英文原版资料“Getting Started with QNX Neutrino: A Guide for Realtime Programmers”为参考,翻译和逐句校对后,对在QNX操作系统下进行应用程序开发及进行资源管理器编写开发等方面,进行了深度整理,旨在帮助想要了解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编程入门之进程和线程(五)
2-6-1-1 QNX编程入门之进程和线程(六)
接前面章节内容继续。
前面我们已经看过了以下有关内容:
现在让我们通过讨论以下内容来结束对同步的讨论:
紧接着后面是一个例子。
正如其名称所暗示的那样,读写锁(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
是轻量级的(在大小和速度方面)。
在多线程程序中发生的另一种常见情况是线程需要等待,直到“某些事情发生”。这个“事情”可以是任何事情!它可能是数据现在可以从设备中获得,或者传送带现在已经移动到适当的位置,或者数据已经提交到磁盘,等等。这里要引入的另一个问题是,多个线程可能需要等待给定的事件。
要做到这一点,我们要么使用条件变量(我们将在下面看到),要么使用更简单的“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()
实际上执行三个不同的步骤:
它必须解锁和再次锁定sleepon
library 中的互斥锁的原因很简单,因为互斥锁的整个思想是确保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 |
消费者检查 |
Consumer |
READY |
INTERRUPT |
消费者调用 |
Consumer |
READY |
INTERRUPT |
|
Free |
READY |
INTERRUPT |
|
Free |
CONDVAR |
INTERRUPT |
时间流逝 |
Free |
CONDVAR |
INTERRUPT |
硬件生成了数据 |
Free |
CONDVAR |
READY |
生产者锁定互斥锁 |
Producer |
CONDVAR |
READY |
生产者设置 |
Producer |
CONDVAR |
READY |
生产者调用 |
Producer |
CONDVAR |
READY |
消费者被唤醒, |
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_ready
的address
被用作唯一的标识符,作为休眠锁会合的对象【rendezvous object】。我们也可以使用“(void *)12345
”轻易替代“&data_ready
”,因为只要标识符是唯一的并且使用一致即可,sleepon
library 就不会在意其他东西。实际上,使用进程中变量的地址可以保证生成进程中唯一的编号,因为,毕竟在同一个进程中没有两个变量具有相同的地址!我们把对“pthread_sleepon_signal()
和pthread_sleepon_broadcast()
之间的区别”的讨论推迟到下一个章节,也就是条件变量中进行讨论。
未完待续,请继续关注本专栏内容……