FSP语言学习(五):共享对象和相互排斥

目录

1. 引言

2. 干扰

2.1 观赏花园问题

2.2 观赏花园模型

3. Java中的相互排斥

4. 建立相互排斥的模型


1. 引言

在之前的中,我们讨论了在一个或多个处理器上执行多个进程的问题,通过在一个Java程序中交错和执行多个并发线程来模拟并发执行。我们解释了如何使用共享原子动作对进程的交互进行建模,但没有解释真正的进程或线程如何交互。在这一章中,我们将转向构建并发程序所涉及的问题,在这些程序中,线程会进行交互通信和合作。

Java程序中两个或多个线程进行交互的最简单方式是通过一个对象,该对象的方法可以被一组线程调用。这个共享对象的状态当然可以被其方法所观察和修改。因此,两个线程可以通过一个线程写入共享对象的状态和另一个线程读取该状态进行交流。同样地,一组线程可以合作更新封装在共享对象中的一些信息。不幸的是,正如我们将解释的那样,这种简单的互动方案并不奏效。
 

2. 干扰

我们已经看到,一组线程的指令的执行可以以任意的方式交错进行。这种交错会导致对共享对象的状态的不正确更新。这种现象被称为干扰。干扰的问题,以及如何处理它,是本篇主要议题。

2.1 观赏花园问题

为了集中讨论线程互动的问题,我们使用了一个被称为 "观赏花园 "的问题的例子,这个例子是Alan Burns和Geoff Davies(1993)提出的。这个问题陈述如下。一个大型观赏花园向公众开放,公众可以通过下图的两个旋转门进入。管理层希望确定在任何时候有多少人在花园里。他们需要一个计算机系统来提供这一信息。

为了进一步简化问题,我们考虑一个允许人们进入但永远不会离开的花园!在这个花园里,人们可以自由选择是否进入。实现观赏花园管理所需的人口统计的并发程序由两个并发的线程和一个共享的计数器对象组成。每个线程控制一个旋转门,并在有人通过旋转门时增加共享计数器。下图描述了该程序的类图。

计数器对象和Turnstile线程是由下图花园小程序的go()方法创建的,其中eastD、westD和counterD是NumberCanvas类的对象。

private void go() {
    counter = new Counter(counterD);
    west = new Turnstile(westD,counter);
    east = new Turnstile(eastD,counter);
    west.start();
    east.start();
}

下程序所示的Turnstile线程通过睡眠半秒,然后调用计数器的increment()方法来模拟游客定期到达花园的情况。在Garden.MAX的游客到达后,run()方法退出,因此,线程终止。

class Turnstile extends Thread {
    NumberCanvas display;
    Counter people;

    Turnstile(NumberCanvas n,Counter c)
        { display = n; people = c; }

    public void run() {
        try{
            display.setvalue(0);
            for (int i=1;i<=Garden.MAX;i++){
            Thread.sleep(500); //0.5 second between arrivals
            display.setvalue(i);
            people.increment();
            }
        } catch (InterruptedException e) {}
    }
}

其余的Counter类比严格意义上的需要更加复杂。额外的复杂性是为了确保程序能独立于Java的任何特定实现来演示干扰的效果。为了确保程序展示出预期的效果,下面的程序确保了任意交错的发生。

class Counter {
    int value=0;
    NumberCanvas display;

    Counter(NumberCanvas n) {
        display=n;
        display.setvalue(value);
    }

    void increment() {
        int temp = value;    //read value
        Simulate.HWinterrupt();
        value=temp+1;       //write value
        display.setvalue(value);
    }
}

它通过使用提供HWinterrupt()方法的Simulate类来做到这一点。该方法在被调用时,有时会通过调用Thread.yield()导致线程切换,有时会省略调用,让当前线程继续运行。这个想法是为了模拟硬件中断,它可以在执行增量时在共享计数器的读写之间的任意时间发生。因此,线程切换可以在任意时间发生。Simulate类由以下代码定义。

class Simulate {
    public static void HWinterrupt() {
        if (Math.random()< 0.5) Thread.yield();
    }
}

下图中运行中的小程序的屏幕截图说明了观赏花园程序的问题。当按下Go按钮时,Garden.go()方法被调用以创建一个Counter对象和两个Turnstile线程。然后每个线程将计数器精确地增加Garden.MAX倍,然后终止。常数Garden.MAX的值被设置为20,因此,当两个Turnstile线程终止时,计数器的显示应该记录有40人进入了花园。事实上,从图可以看出,计数器只记录了31人。那些失踪的人去哪了?为什么计数器的9个增量会丢失?为了研究原因,我们建立了一个观赏花园问题的模型。

2.2 观赏花园模型

我们通常将每个对象或对象集建模为一个FSP过程。然而,为了找出观赏花园程序运行不正确的原因,我们必须在存储访问的层面上进行建模。因此,该模型包括一个VAR过程,描述对一个存储位置的读写访问。这个存储位置是由Counter类的人们实例封装的值变量。完整的模型在下图中描述。

我们可能会感到惊讶,因为没有明确提到增量动作。相反,增量是通过TURNSTILE内部的INCREMENT定义使用读写动作来建模的。每个线程对象,包括东线和西线,都有自己的读写动作副本,构成了增量操作或程序。这模拟了实际Java程序中发生的情况,因为方法是可重入的,因此构成方法的指令可以代表同时执行该方法的线程交错进行。换句话说,方法的激活不是原子性的动作。TURNSTILE的LTS在图中给出。

上面,进程VAR的字母表已经被明确声明为集合。我们以前没有使用过集合常数。集合常数可以用在我们以前明确声明的动作标签集合的地方。集合只是对模型描述的一种缩写方式。VarAlpha的声明方式如下。

set VarAlpha = {value.{read[T],write[T]} }

TURNSTILE进程的字母表使用字母表扩展结构+{...}对这个集合进行扩展。这是为了确保没有意外的自由动作。例如,如果一个特定值的VAR写入没有与另一个进程共享,那么它可以自主地发生。一个TURNSTILE进程从不参与value.write[0]的动作,因为它总是增加它所读取的值。然而,由于这个动作包含在TURNSTILE的字母表扩展中,尽管它没有在进程定义中使用,但它被阻止自主发生。

TURNSTILE进程与它的Java实现略有不同,因为它并不运行固定的到达次数,而是可以在任何一点结束。然而,它不能在更新共享变量值的过程中结束。结束动作只被接受作为到达动作的替代。此外,TURNSTILE被定义为递归,所以分析(在下面讨论)不会报告虚假的死锁,如果我们在动作结束后使用STOP就会出现这种情况。请注意,共享的变量VAR不仅被东边和西边的旋转门所共享,而且还被用于检查目的的显示所共享。

在开发了观赏花园程序的模型后,我们可以用它做什么呢?好吧,我们可以使用LTSA工具对模型进行动画处理,以产生特定输入情况下的动作轨迹。例如,下图的轨迹说明了有一个东面的到达和一个西面的到达,然后发生结束的情况。

追踪的结果是正确的,在两次到达后,计数器的值为2。然而,在找出程序的错误之前,我们可能会尝试许多输入场景。为了自动寻找错误,我们将一个TEST过程与现有的模型结合起来,当一个错误的动作跟踪发生时发出信号。这个过程定义如下。

TEST       = TEST[0],
TEST[v:T]  =
     (when (v<N){east.arrive,west.arrive}->TEST[v+1]
     |end->CHECK[v]
     ),
CHECK[v:T] =
    (display.value.read[u:T] ->
       (when (u==v) right -> TEST[v]
       |when (u!=v) wrong -> ERROR
       )
    )+{display.VarAlpha}.

该过程计算 east.arrive 和 west.arrive 动作的总数。当一个结束动作发生时,随之而来的是共享变量的更新完成,它检查存储的值是否与到达事件的总数相同。如果不是,就会通过进入ERROR状态来宣布错误。ERROR(像STOP)是一个预定义的FSP本地进程(或状态)。它在等价的LTS中总是被编号为-1。同样,字母扩展被用来确保没有以display为前缀的动作可以自主发生。

TEST过程与现有模型的结合如下。

||TESTGARDEN = (GARDEN || TEST).

我们现在可以要求LTSA分析工具进行穷举搜索,看是否可以达到TEST中的ERROR状态,如果可以,则产生一个例子的跟踪。产生的跟踪是。

Trace to property violation in TEST:
     go
     east.arrive
     east.value.read.0
     west.arrive
     west.value.read.0
     east.value.write.1
     west.value.write.1
     end
     display.value.read.1
     wrong

这个追踪清楚地表明了原始Java程序的问题。由于共享变量没有被原子化地更新,增量被丢失。因此,东边和西边的转盘都是读值为0,写值为1。如果东边的增量在西边的增量开始之前完成,或者相反,那么结果就会是2(如前面的跟踪)。

破坏性的更新,由读写动作的任意交错引起,被称为干扰。

在真正的并发程序中,干扰错误是非常难以定位的。它们不经常发生,也许是由于设备中断和应用程序I/O请求的某些特定组合。即使经过大量的测试,它们也可能不会被发现。我们不得不在示例程序中加入一个模拟中断来演示这个错误。如果没有模拟中断,程序仍然是不正确的,尽管错误的行为可能不会在所有系统上表现出来。

干扰问题的一般解决方案是让访问共享对象的方法对该对象进行互斥访问。这可以确保更新不会被并发的更新打断。正如我们在下面的章节中所看到的,具有互斥访问的方法可以被建模为原子动作。

3. Java中的相互排斥

在Java中,一个方法的并发激活可以通过在该方法前缀上关键字synchronized来实现互斥。

通过从Counter派生出一个SynchronizedCounter类,并使子类中的增量方法同步化,可以纠正Ornamental Garden程序中的Counter类,如程序所示。

class SynchronizedCounter extends Counter {

    SynchronizedCounter(NumberCanvas n)
        {super(n);}

    synchronized void increment() {
        super.increment();
    }
}

Java将一个锁与每个对象联系起来。Java编译器会在执行同步方法的主体之前插入代码来获取锁,并在方法返回之前释放锁。并发线程被阻断,直到锁被释放。由于每次只有一个线程可以持有锁,因此只有一个线程可以执行同步方法。如果这是唯一的方法,就像在这个例子中一样,共享对象的互斥就得到了保证。如果一个对象有一个以上的方法,为了确保对对象状态的互斥访问,所有的方法都应该是同步的。

对一个对象的访问也可以通过使用synchronized语句来实现互斥。

synchronized (object) { statements }

这在执行括号内的语句块之前获得了被引用对象的锁,并在退出语句块时释放它。例如,纠正这个例子的另一种方法(但不太优雅)是修改Turnstile.run()方法,使用。

synchronized(people) {people.increment();}

这就不那么优雅了,因为共享对象的用户有责任施加锁,而不是将其嵌入到共享对象本身。由于不是所有对象的用户都可能负责任地行动,所以它对干扰的安全性也可能较差。

下图描述了修正后的观赏花园程序的输出。唯一的变化是使用上面程序中定义的类,而不是原来的计数器类。在按下 "Go "键之前,通过点击 "Fix It "复选框进行这一改变。

一旦一个线程通过执行一个同步方法获得了一个对象的锁,该方法可以自己从同一个对象(直接或间接)调用另一个同步方法,而不必等待再次获得锁。锁会计算它被同一个线程获取了多少次,并且不允许另一个线程访问该对象,直到有相当数量的释放。这种锁策略有时被称为递归锁,因为它允许递归的同步方法。比如说。

public synchronized void increment(int n) {
    if (n>0) {
        ++value;
        increment(n-1);
    } else return;
}

如果Java中的锁不是递归的,它将导致一个调用线程永远被阻塞,等待获得它已经持有的锁!这是一个相当不可能的递归版本的方法,它的值增加了n。

4. 建立相互排斥的模型

纠正观赏园程序模型的最简单方法是以完全相同的方式在Java程序中添加锁。为了简单起见,我们忽略了Java锁是递归的这个细节,因为锁是否是递归的对这个问题没有影响。一个(非递归的)锁可以通过进程来建模。

LOCK = (acquire->release->LOCK).

LOCKVAR这个组合将一个锁和一个变量联系起来。在GARDEN的定义中,它被替换为VAR。

||LOCKVAR = (LOCK || VAR).

字母表VarAlpha被修改如下,以包括额外的锁定动作。

set VarAlpha = {value.{read[T],write[T],
                       acquire, release}}

最后,必须修改TURNSTILE的定义,以便在访问变量之前获得锁,之后再释放。

TURNSTILE = (go    -> RUN),
RUN       = (arrive-> INCREMENT
            |end   -> TURNSTILE),
INCREMENT = (value.acquire
             -> value.read[x:T]->value.write[x+1]
             -> value.release->RUN
            )+VarAlpha.

我们可以用与之前完全相同的方式用TEST来检查这个模型。详尽的搜索并没有发现任何错误。因此,我们已经机械地验证了这个新版本的模型满足以下属性:当按下停止键时,计数值等于到达的总人数。下面是新模型的一个执行轨迹样本。

go
east.arrive
east.value.acquire
east.value.read.0
east.value.write.1
east.value.release
west.arrive
west.value.acquire
west.value.read.1
west.value.write.2
west.value.release
end
display.value.read.2
right

现在我们已经证明,我们可以使用锁使共享动作不可分割或原子化,我们可以抽象出变量和锁的细节,并直接以其同步方法为共享对象建模。我们可以通过隐藏动作来机械地执行抽象化。例如,我们可以通过以下方式描述SynchronizedCounter类的行为(在一个有限的整数范围内)。

const N = 4
range T = 0..N

VAR = VAR[0],
VAR[u:T] = ( read[u]->VAR[u]
           | write[v:T]->VAR[v]).

LOCK = (acquire->release->LOCK).

INCREMENT = (acquire->read[x:T]
             -> (when (x<N) write[x+1]
                 ->release->increment->INCREMENT
                )
             )+{read[T],write[T]}.

||COUNTER = (INCREMENT||LOCK||VAR)@{increment}.

INCREMENT的定义与之前使用的略有不同。当子句确保只有当存储的值小于N时才能发生增量动作。换句话说,增量不允许溢出范围T。字母表声明@{increment}意味着读、写、获取和释放成为COUNTER的内部动作(tau)。下图描述了将COUNTER最小化后的LTS。

我们可以描述一个产生完全相同的LTS的单一过程。

COUNTER = COUNTER[0],
COUNTER[v:T] = (when (v<N) increment->COUNTER[v+1]).

这是一个更抽象的、因此也是更简单的共享计数器对象的模型,它有同步的增量方法。我们在上面证明了(通过LTSA最小化),它的可观察行为与更复杂的定义完全相同。可以添加一个显示动作来读取计数器的值,如下所示。

DISPLAY_COUNTER = COUNTER[0],
COUNTER[v:T] = (when (v<N) increment->COUNTER[v+1]
               |display[v] -> COUNTER[v]).

下图描述了将DISPLAY_COUNTER最小化后的LTS。

要在Java类中实现这个动作,我们只需添加同步方法。

public synchronized int display() {
     return value;
}

在下面的内容中,我们通常在这个抽象层次上对共享对象进行建模,忽略锁和互斥的细节(如在Java中使用同步方法所提供的)。每个共享对象都被建模为一个FSP进程,此外还将每个Java线程建模为一个FSP进程。程序的模型并不区分主动实体(线程)和被动实体(共享对象)。它们都被建模为有限状态机。这种统一的处理方式有利于分析。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值