NSPR 简介

Netscape 可移植运行时 (NSPR) API 允许兼容的应用程序以平台无关的方式使用系统功能,例如线程、线程同步、I/O、间隔计时、原子操作以及其他一些低级服务。本章介绍了关键的 NSPR 编程概念,并通过示例代码进行说明。

NSPR 并不提供移植现有代码的平台。它必须从软件项目的开始就使用。

NSPR 命名约定

NSPR 类型、函数和宏的命名遵循以下约定

  • NSPR 导出的类型以 PR 开头,后跟驼峰式大小写声明,例如:PRIntPRFileDesc

  • 函数定义以 PR_ 开头,后跟驼峰式大小写声明,例如:PR_Read`PR_JoinThread`

  • 预处理器宏以字母 PR 开头,后跟全部大写字符,并用下划线 (_) 分隔,例如:PR_BYTES_PER_SHORTPR_EXTERN

NSPR 线程

NSPR 提供了一个执行环境,该环境促进了轻量级线程的使用。每个线程都是一个执行实体,它独立于同一进程中的其他线程进行调度。线程拥有数量有限的真正属于自己的资源。这些资源包括线程堆栈和 CPU 寄存器集(包括 PC)。

对于 NSPR 客户端,线程由指向类型为 PRThread` 的不透明结构的指针表示。线程由客户端的显式请求创建,并保持为有效的独立执行实体,直到它从其根函数返回或进程异常终止。(PRThread 和用于创建和操作线程的函数在 线程 中进行了详细描述。)

NSPR 线程是轻量级的,因为它们比完整的进程更便宜,但它们不是免费的。它们通过依靠其包含的进程来管理它们访问的大多数资源来实现成本降低。这一点以及线程与同一进程中的其他线程共享地址空间的事实,使得记住线程不是进程 非常重要。

NSPR 线程在两个独立的域中进行调度

  • **本地线程**仅在进程内调度,并完全由 NSPR 处理,方法是在每个不支持线程的主机操作系统 (OS) 上完全模拟线程,或者通过使用每个支持线程的主机 OS 的线程功能来模拟相对大量的本地线程,方法是使用相对较少的原生线程。

  • **全局线程**由主机 OS(而不是 NSPR)调度,无论是在进程内还是跨进程在整个主机上。全局线程对应于主机 OS 上的原生线程。

NSPR 线程也可以是用户线程或系统线程。NSPR 提供了一个函数 PR_Cleanup,用于同步进程终止。PR_Cleanup 在返回之前等待最后一个用户线程退出,而在确定进程何时应退出时忽略系统线程。这种安排意味着系统线程不应该具有需要安全存储的易失性数据。

NSPR 线程的优先级松散地基于客户端提供的提示,有时受底层操作系统的限制。因此,优先级没有严格定义。有关更多信息,请参阅 线程调度

通常,最好创建具有正常优先级的本地用户线程,并让 NSPR 根据每个主机 OS 的情况处理详细信息。通常没有必要显式创建全局线程,除非您计划仅将代码移植到提供您熟悉的线程服务的平台,或者除非该线程将执行可能直接调用阻塞 OS 函数的代码。

线程还可以附加“每个线程数据”。每个线程都内置了每个线程的错误号和错误字符串,在 NSPR 操作失败时会更新这些错误号和错误字符串。NSPR 客户端也可以定义自己的每个线程数据。有关详细信息,请参阅 控制每个线程的私有数据

线程调度

NSPR 线程按优先级调度,并且可以被抢占或中断。以下部分简要介绍了 NSPR 对线程调度的这三个方面的处理方法。

有关用于线程调度的 NSPR API 的参考信息,请参阅 线程

设置线程优先级

NSPR 支持的主机操作系统在用于支持线程优先级的机制方面差异很大。通常,优先级较高的 NSPR 线程相对于优先级较低的线程在统计上具有更好的运行机会。但是,由于在各种主机平台上为线程提供执行载体的多种策略,因此优先级在 NSPR 中并不是一个明确定义的抽象概念。充其量,它们旨在指定相对于优先级较低的线程,优先级较高的线程可能期望的 CPU 时间量的首选项。此首选项仍然受资源可用性的影响,并且不得用于替代正确的同步。有关线程同步的更多信息,请参阅 NSPR 线程同步

OS 供应商在其实现的内核支持线程优先级方面提供的服务不一致,进一步加剧了这个问题。NSPR 假定全局线程的优先级不可管理,但主机 OS 将执行某种公平调度。通常,最好创建具有正常优先级的本地用户线程,并让 NSPR 和主机处理细节。

在某些 NSPR 配置中,可能存在任意数量(可能很多)的本地线程,它们由数量更少的**虚拟处理器**(全局线程的内部应用)支持。在这种情况下,每个虚拟处理器将与一定数量的本地线程相关联,尽管确切的本地线程及其数量可能会随着时间的推移而变化。NSPR 保证对于每个虚拟处理器,最高优先级、可调度的本地线程是正在执行的线程。此线程实现策略称为**M x N 模型**。

抢占线程

抢占是在任意点从就绪线程中夺取控制权并将其交给另一个合适线程的行为。可以将其视为将正在执行的线程添加到其相应优先级的就绪队列的末尾,然后简单地运行调度算法以找到最合适的线程。选择的线程可能是优先级更高的线程、相同优先级的线程,甚至可能是同一个线程。它将不是优先级较低的线程。

某些操作系统无法被抢占(例如,Mac OS 和 Win 16)。这使得它们在支持任意代码方面存在一定风险,即使代码是解释执行的(Java)。其他系统没有线程感知能力,其运行时库也不是线程安全的(大多数版本的 Unix)。这些系统可以支持可以被抢占的本地级线程抽象,但存在库损坏 (libc) 的风险。其他操作系统具有原生线程概念,其库是线程感知的并支持锁定。但是,如果本地线程也存在并且可以被抢占,则它们容易发生死锁。目前,唯一安全的解决方案是关闭抢占(运行时决策)或仅抢占全局线程。

中断线程

NSPR 线程是可以中断的,但存在一些约束和不一致之处。

要中断线程,PR_Interrupt 的调用者必须拥有目标线程的 NSPR 引用 (PRThread)。当目标线程被中断时,它会从其阻塞的点重新调度,并显示一个状态错误,指示它被中断。NSPR 仅识别线程可能被中断的两个区域:等待条件变量和等待 I/O。在后一种情况下,中断确实会取消 I/O 操作。在这两种情况下,被中断都不意味着线程的消失。

NSPR 线程同步

线程同步有两个方面:锁定和通知。锁定阻止对某些资源(例如共享数据)的访问:也就是说,它强制互斥。通知涉及在协作线程之间传递同步信息。

在 NSPR 中,类型为 PRLock 的**互斥锁**(或**mutex**)控制锁定,并且类型为 PRCondVar 的关联**条件变量**在线程之间传递状态变化。当程序员将 mutex 与任意数据集合相关联时,mutex 会在数据周围提供一个保护性的**监视器**。

锁和监视器

通常,监视器是由 mutex、一个或多个条件变量以及被监视的数据组成的概念实体。此泛型意义上的监视器不应与 Java 编程中使用的监视器类型混淆。除了 PRLock 之外,NSPR 还提供了另一种 mutex 类型 PRMonitor,它是可重入的,并且只能有一个关联的条件变量。PRMonitor 旨在与 Java 一起使用,并反映了 Java 对线程同步的方法。

要访问监视器中的数据,执行访问的线程必须持有 mutex,也称为“处于监视器中”。互斥保证一次只有一个线程可以处于监视器中,并且任何线程在未处于监视器中时都不能观察或修改被监视的数据。

监视是为了保护数据,而不是代码。**被监视的不变式**是关于被监视数据的布尔表达式。只有当线程处于监视器中(持有监视器的 mutex)时,该表达式才可能为假。此要求意味着当线程首次进入监视器时,不变式表达式的求值必须产生 true。线程还必须在退出监视器之前恢复被监视的不变式。因此,表达式的求值在执行的这一点也必须产生 true。

一个简单的示例如下所示。假设一个对象有三个值,v1v2sum。不变式是第三个值是其他两个值的总和。用数学表示,不变式是 sum = v1 + v2。对 v1v2 的任何修改都需要修改 sum。由于这是一个复杂的操作,因此必须对其进行监视。此外,对 sum 的任何类型的访问也必须受到监视,以确保 v1v2 均未处于变化状态。

注意

不变式表达式的求值是一个概念性要求,在实践中很少执行。在设计期间正式定义表达式、将其写下来并遵守它非常有价值。在开发期间实现表达式并在适当的地方对其进行测试也很有用。线程在进入和退出监视器时都会对表达式的求值做出绝对的断言。

获取锁是一个同步操作。一旦调用锁原语,线程只有在获取到锁后才会返回。如果另一个线程(或同一个线程)已经持有锁,则调用线程会被阻塞,等待情况好转。这种阻塞状态不可中断,也没有超时机制。

条件变量

条件变量促进线程之间的通信。可用的通信是一个无语义的通知,其上下文必须由程序员提供。条件与单个监视器紧密相关。

条件与监视器之间的关联是在创建条件变量时建立的,并且该关联在条件变量的生命周期内一直存在。此外,条件与监视器中的一些数据之间存在静态关联。这些数据将在监视器的保护下由程序操作。线程可以等待通知与关联数据状态变化相关的条件。其他线程可以在发生变化时通知该条件。

条件变量始终受到监视。对条件的相关操作始终在监视器内部执行。它们用于传达受监视数据状态的变化(但仍然保留受监视的不变性)。条件变量允许一个或多个线程等待预定的条件存在,并允许另一个线程在条件发生时通知它们。条件变量本身不承载状态更改的语义,而只是提供一种指示某些内容已更改的机制。程序员有责任将条件与数据的状态关联起来。

可以设计一个线程来等待某些受监视数据中存在特定情况。由于情况的本质不是条件的属性,因此程序必须自行测试。由于此测试涉及受监视数据,因此必须在监视器内部进行。wait 操作会原子地退出监视器并将调用线程阻塞在等待条件状态中。当线程在等待后恢复时,它将重新进入监视器,从而使对数据的操作安全。

等待条件的线程和通知它的线程之间存在微妙的交互。通知必须在监视器内部发生——与通知者操作的数据相同的监视器。在伪代码中,序列如下所示

enter(monitor);
... manipulate the monitored data
notify(condition);
exit(monitor);

对条件的通知不会累积。当通知发生时,也不要求任何线程在等待条件。等待条件的代码设计必须考虑到这些事实。因此,等待线程的伪代码可能如下所示

enter(monitor)
while (!expression) wait(condition);
... manipulate monitored data
exit(monitor);

在从等待重新调度后再次评估布尔表达式的需要可能看起来没有必要,但它对于程序的正确执行至关重要。通知将等待条件的线程提升到就绪状态。该线程何时实际被调度由线程调度程序确定,并且无法预测。如果多个线程实际上正在处理通知,则其中一个或多个线程可能会在显式由通知提升的线程之前被调度。其中一个线程可能会进入监视器并执行通知指示的工作,然后退出。在这种情况下,线程将仅从等待恢复,却发现没有事情可做。

例如,假设函数的定义规则是它应该等到有对象可用,并且它应该返回对该对象的引用。如下编写代码可能会返回空引用,从而违反函数的不变性

void *dequeue()
{
   void *db;
   enter(monitor);
   if ((db = delink()) == null)
   {
      wait(condition);
      db = delink();
   }
   exit(monitor);
   return db;
}

相同的函数将更适当地编写如下

void *dequeue()
{
   void *db;
   enter(monitor);
   while ((db = delink()) == null)
      wait(condition);
   exit(monitor);
   return db;
}

注意

注意PR_WaitCondVar 的语义假设监视器即将退出。此假设意味着必须在调用 PR_WaitCondVar 之前恢复受监视的不变性。否则会导致细微但痛苦的错误。

要安全地修改受监视的数据,线程必须位于监视器中。由于没有其他线程可以在监视器外部修改或(在大多数情况下)甚至观察受保护的数据,因此线程可以安全地进行所需的任何修改。当更改完成后,线程通知与数据关联的条件并使用 PR_NotifyCondVar 退出监视器。从逻辑上讲,每个此类通知都会将一个等待条件的线程提升到就绪状态。另一种通知形式(PR_NotifyAllCondVar)将所有等待条件的线程提升到就绪状态。如果没有线程在等待,则通知将不起作用。

等待条件变量是一个可中断的操作。另一个线程可以将等待线程作为目标并发出 PR_Interrupt,导致等待线程恢复。在这种情况下,等待操作的返回值表示失败,并明确指示失败的原因是中断。

调用 PR_WaitCondVar 也可能恢复,因为等待调用上指定的时间间隔已过期。但是,此事实无法明确传递,因此不会尝试这样做。如果程序的逻辑允许对条件进行等待计时,则时钟必须被视为受监视数据的一部分,并且在调用返回时重新断言经过的时间量。从哲学上讲,超时应视为显式通知,因此需要在恢复时测试受监视的数据。

NSPR 示例代码

此处链接的文档提供了两个示例程序,包括详细的注释:layer.htmlswitch.html。除了这些带注释的 HTML 版本之外,这些相同的示例还以纯源代码形式提供。