线性一致性理解Linearizability

终于理解了线性一致性,很开心

1. 线性一致性来源

  线性一致性是Maurice P. Herlihy 与 Jeannette M. Wing共同提出的关于并行对象行为正确性的一个条件模型,在《多处理器并发编程的艺术》这本书中提及。原文用的是Linearizability, 目前看翻译有的叫线性一致性,有的叫可线性化性。 这个模型的解释其实还是挺复杂的,下面d大部分的理解来源于stackoverflow的这篇解答

2. 线性一致性解析

  当我们在解释并发处理是否正确的时候,我们常常是使用偏序(partial order)来拓展到全局有序(total order),查看并发操作的历史序列是否是正确的要比直接观察过程来判断并发操作是否是正确的要容易的多。这里的操可以是一个方法调用,后面我们也是拿方法调用作为一个操作来进行举例阐述。

1. 单线程执行的正确性

  首先,我们先把并发操作放在一边,先考虑单个线程的处理程序。我们假设有一个历史的处理序列H_S(history sequence), 这个历史的处理序列由一系列的events构成,event可能是Invoke也可能是Response,这个时候这个操作序列是这样的:每个Invoke后面都紧跟着他对应的Response.这里的紧跟着就是在Invoke_i 和 Response_i 之间不会有其他的invoke或者response.也就是对方法的调用是串行的。
H_S有可能是这样的

H_S : I1 R1 I2 R2 I3 R3 ... In Rn

(Ii 代表第i个Invoke, Ri代表了第i个Response)

因为没有并发,这里我们很容易就可以判断出来H_S是一个正确的操作序列,这里所谓的正确就是这个序列产生的结果是和我们编写程序时候预期的结果是一样的。这样的描述可能有点抽象,我们可以举个例子,把操作对应的方法定义如下

//int i = 0; i is a global shared variable.
int inc_counter() {
   while(true){
    int old_i=i;
    int j = old_id++;
    if(cas(&i,old_i,j)==0){     //没有操作成功,就循环操作,操作成功了就返回
	continue;
    }else{
    	return j;
    }
   }
}

对应的cas操作是一个原子操作

int cas(long *addr, long old, long new)
{
    /* Executes atomically. */
    if(*addr != old)
        return 0;
    *addr = new;
    return 1;
}

则对inc_counter()的调用,使用单线程的话,判断程序是否是正确的,就是如果我们不停的调用inc_counter(),对应的Ri(res)中的结果res(i)的值肯定是递增的,满足了是递增的,那么就是和我们编写程序想要达到的目标是一致的。

2. 并发的运行历史分析

  实际情况下,程序的运行大多是多线程的,有可能我们的应用程序中会有A B两个线程在运行这个方法,当我们运行项目的时候我们也可以得到一个并发的运行历史,称之为H_C (History_Concurrent),像在H_S中一样,我们也使用Ii~Ri来表示方法的调用。因为两个线程是并发的,所以A B产生的调用处理在时间上可能是相互重叠的,所以从时间维度上我们可能会得到下面的一个操作历史

thread A:         IA1----------RA1               IA2-----------RA2
thread B:          |      IB1---|---RB1    IB2----|----RB2      |
                   |       |    |    |      |     |     |       |
                   |       |    |    |      |     |     |       |
real-time order:  IA1     IB1  RA1  RB1    IB2   IA2   RB2     RA2
                ------------------------------------------------------>time

对应的是这样的

H_C : IA1 IB1 RA1 RB1 IB2 IA2 RB2 RA2

这个时候我们应该如何判断序列H_C是正确的呢,我们可以根据下面的规则将H_C 进行重排序得到H_RO(History_Reorder)

如果一个方法调用m1 发生在另一个m2调用之前,则m1在重新排序的序列中必须在m2之前。
在这种规则下,我们说H_C等效于H_RO(history_reorder)。
这意味着如果Ri在H_C中的Ij前面,则必须确保Ri在重新排序的序列中仍在Ij的前面,i和j没有它们的顺序,我们也可以使用a,b,c …。

H_RO有两个属性

  1. 尊重编程顺序,也就是单线程的执行顺序,所见即所得
  2. 保留真实发生的事件(respone的值)

在不考虑上面的两条属性的情况下,我们可以将H_C重排序为以下几种类型(下面用H_S来代表H_RO)

H_S1: IA1 RA1 IB1 RB1 IB2 RB2 IA2 RA2
H_S2: IB1 RB1 IA1 RA1 IB2 RB2 IA2 RA2
H_S3: IB1 RB1 IA1 RA1 IA2 RA2 IB2 RB2
H_S4: IA1 RA1 IB1 RB1 IA2 RA2 IB2 RB2

但是我们不能排出下面的顺序

H_S5: IA1 RA1 IA2 RA2 IB1 RB1 IB2 RB2

因为在H_C中,IB1~RB1是在IA2~RA2之前发生的

  那么即使有了这些序列,我们如何确定我们的H_C是正确(correct)的呢?(当下我们只讨论这个序列是correct,而不是讨论程序的correctness),这里所谓的正确和刚才单线程情况下讨论的正确性是一致的。就是这个序列产生的结果是否和我们编写程序时候预期的结果是一样的
  答案很简单,只要对应的H_S有一个是和我们预期的结果是一样的(正确性的条件),那么就可以认为H_C是可线性化的,把H_S称为H_C的线性化,同时认为H_C是一个正确的执行,也是我们期望程序表现出来的正常的结果。如果做过并发编程,可能你就会遇到过看起来正常的程序,执行的结果却和你认为的结果相去甚远。

还拿上面的程序举例,假设变量i的初始值为0,则程序运行可能有这样的一个序列,这里我们加上了response的结果

thread A:         IA1----------RA1(1)                  IA2------------RA2(3)
thread B:          |      IB1---|------RB1(2)    IB2----|----RB2(4)    |
                   |       |    |        |        |     |     |        |
                   |       |    |        |        |     |     |        |
real-time order:  IA1     IB1  RA1(1)  RB1(2)    IB2   IA2   RB2(4)   RA2(3)
                ---------------------------------------------------------->time

对应的H_C

H_C : IA1 IB1 RA1(1) RB1(2) IB2 IA2 RB2(4) RA2(3)

对应的reorder之后的H_S

H_S1: IA1 RA1(1) IB1 RB1(2) IB2 RB2(4)  IA2 RA2(3) 
H_S2: IB1 RB1(2) IA1 RA1(1) IB2 RB2(4)  IA2 RA2(3) 
H_S3: IB1 RB1(2) IA1 RA1(1) IA2 RA2(3)  IB2 RB2(4) 
H_S4: IA1 RA1(1) IB1 RB1(2) IA2 RA2(3)  IB2 RB2(4) 

然后使用上面提到的H_RO的两个属性,上面的H_S序列中只有H_S4是符合预期的一个执行序列,那么也就是说H_C是可线性化的,把H_S4称为H_C的线性化,同时认为H_C是一个正确的执行。

3. 应用程序的线性化判断

1. 程序线性化的理论要求

  目前为止,我们终于搞明白了,对于一个并发的项目,什么是可线性化的运行历史(linearizable history ),那么我们又该如何评价一个程序是否是可线性化的呢,书中暗表:

The basic idea behind linearizability is that every concurrent history is equivalent, in the following sense, to some sequential history. [The Art of Multiprocessor Programming 3.6.1 : Linearizability] (“following sense” is the reorder rule I have talked about above)
线性化背后的基本思想是,在遵循reorder的规则下,每个并发历史都等效于某些顺序历史。

也就是说对应一个应用程序来说,如果他执行的每一个H_C都能通过reorder rule转化为一个正确的H_S,那么就说这个应用程序是可线性化的。

2. linearization point 检查

  但是,将所有并发历史H_C重新排序为顺序历史H_S以判断程序是否可线性化的方法仅在理论上可行。在实践中,我们面临着由几十个线程对同一个方法的大量调用。我们不能对它们的所有历史进行重新排序。我们甚至无法列出一个复杂程序的所有并发历史(H_C),所以作者又提出来另一个叫linearization point的概念:

The usual way to show that a concurrent object implementation is linearizable is to identify for each method a linearization point where the method takes effect.
[The Art of Multiprocessor Programming 3.5.1 : Linearization Points]
表明并发对象的操作是可线性化的通常方法是为每个方法确定一个当前方法生效的线性化点。

  们将围绕“并发对象”来讨论上面相关的问题(如果每个线程操作的都是非并发的对象那么程序肯定是正确的)。并发对象的实现一般是有一些方法来访问并发对象的数据。而且多线程共享一个并发对象。因此,当他们通过调用对象的方法并发访问对象时,并发对象的实现者必须确保并发方法调用的正确性。
  最重要的是要理解 方法的linearization point,同样的,where the method takes effect这句描述也确实比较难以理解,下面举一些例子来进行解释。

假如我们有下面的方法

//int i = 0; i is a global shared variable.
int inc_counter() {
    int j = i++;
    return j;
}

很容易发现这个方法存在并发问题,比如我们将i++翻译成汇编语言可以得到

#Pseudo-asm-code
Load   register, address of i
Add    register, 1
Store  register, address of i

因此,两个同时执行i++的线程有可能产生下面的并发历史H_C

thread A:         IA1----------RA1(1)                  IA2------------RA2(3)
thread B:          |      IB1---|------RB1(1)    IB2----|----RB2(2)    |
                   |       |    |        |        |     |     |        |
                   |       |    |        |        |     |     |        |
real-time order:  IA1     IB1  RA1(1)  RB1(1)    IB2   IA2   RB2(2)   RA2(3)
                ---------------------------------------------------------->time

对于这样的并发历史,无论你怎样reorder,都不能得到一个正确的sequential history (H_S)。

我们需要使用下面的方式重写相关的代码

//int i = 0; i is a global shared variable.
int inc_counter(){
    //do some unrelated work, for example, play a popular song.
    lock(&lock);
    i++;
    int j = i;
    unlock(&lock);
    //do some unrelated work, for example, fetch a web page and print it to the screen.
    return j;
}

  这样的话,应该能够理解inc_counter()方法的linearization point了吧,就是整个lock和unlock中间的争议区域critial section,因为在多线程调用inc_counter()的时候,只有争议区域保持原子性的执行才能保证方法的正确性。改良后的inc_counter()方法的response是全局变量i的递增值,有可能是像下面的序列

thread A:         IA1----------RA1(2)                 IA2-----------RA2(4)
thread B:          |      IB1---|-------RB1(1)    IB2--|----RB2(3)    |
                   |       |    |        |         |   |     |        |
                   |       |    |        |         |   |     |        |
real-time order:  IA1     IB1  RA1(2)  RB1(1)     IB2 IA2   RB2(3)   RA2(4)


明显的,上面的序列可以转换为下面的合法sequential history (H_S)

IB1 RB1(1) IA1 RA1(2) IB2 RB2(3) IA2 RA2(4)  //a legal sequential history

我们对IB1~RB1IA1~RA1进行了重排序,因为他们在真实的处理时间上面有重叠,所以可以使用任意一个在前的排序方式。同时依据有效的H_S我们可以判断在H_C当中是IB1~RB1先于 IA1~RA1进入争议区域critial section

上面的例子比较简单,我们再来看一个例子

//top is the tio
void push(T val) {
    while (1) {
        Node * new_element = allocte(val);
        Node * next = top->next;
        new_element->next = next;
        if ( CAS(&top->next, next, new_element)) {  //Linearization point1
            //CAS success!
            //ok, we can do some other work, such as go shopping.
            return;
        }
        //CAS fail! retry!
    }
}

T pop() {
    while (1) {
        Node * next = top->next;
        Node * nextnext = next->next;
        if ( CAS(&top->next, next, nextnext)) { //Linearization point2
            //CAS succeed!
            //ok, let me take a rest.
            return next->value;
        }
        //CAS fail! retry!
    }
}

这是一个充满bug的lock-free stack的算法实现,请忽略算法的细节。我只是想要展示一下push()pop()linearization point,在代码的注释中已经标注了相应的linearization point。想象一下假如有许多线程重复调用push()和pop(),它们将在CAS步骤中进行排序。其他步骤似乎无关紧要,因为无论它们同时执行什么,它们对stack的最终影响(精确地说是top变量)都取决于CAS步骤(线性化点)的顺序。如果我们可以确保线性化点真正起作用,则并发堆栈是正确的。即使H_C非常的长,但是我们可以确认必须存在与H_C等效的合法序列。

因此,如果要实现并发对象,那么如何判断程序的正确性呢?您应该确定每个方法的线性化点,并仔细考虑(甚至证明)它们将始终保持并发对象的不变性。然后,所有方法调用的顺序H_C可以扩展到至少一个合法的总顺序(事件的顺序历史记录H_S),该顺序满足并发对象的顺序规范。

3. 简单总结

  这里简单的再来总结一下线性化的含义,之前看过一些书和一些相关文章,可能每个作者都试图用自己的方式来阐述什么是可线性化,所以很多时候只是注意到了可线性化的其中一方面的特点,这次通过这个很好的回答的学习也算是真正了解了可线性化的含义。我们可以尝试从几个层面去阐述线性化,线性一致性最开始是用在多处理器编程当中,一般是一个进程多个线程这个时候存储是共享的,为了实现程序的线性一致性,主要需要保证的是程序在争议区执行的原子性来保证线性一致性。后面逐渐发展出来分布式系统,这个时候强调分布式系统的一致性,不仅要关注系统对执行的原子化保证,还要了解分布式系统对多副本的处理方式,这个时候多个并发使用的存储并不一定是同一个存储空间。
还有一点是非常重要的,无论在哪个层级定义线性化,线性化的含义总是:
线性化描述的是程序在外部调用的情况下系统总体对外响应的正确性。这个系统可以是一个java并发对象,可以是一个应用处理软件(单机的扫雷游戏),也可以是一个通过网络访问的数据存储应用(Redis),或者是一个分布式的存储系统(Zookeeper)等。线性化只是对系统提出了这些要求,但是系统具体怎么实现的他是不关注的。
而且,并非系统都是线性化的,因为线性化对系统要求比较严格,会影响系统的吞吐量已经系统的复杂度,所以很多系统可能都工作在非线性话的状态,比如MYSQL常用的事务的隔离级别中的脏读,读提交,可重复读等都不是线性一致性的。

1. 程序中对共享对象的操作的可线性化

  简单的概括来说,如果一个对象是线性化的,可以理解为他的所有方法都是原子的(atomic),就像是java的synchronized方法一样,而且操作的方法必须立即执行,不能使用lazy模式。这里隐含的一个条件是这个对象肯定是多个线程共享操作的。

2. 应用程序的线性一致性

  同样的,如果把线性化扩展到一个应用程序的话,那么该应用程序的线性化可以描述为,应用程序对于输入的处理都是原子性的。程序的输出的正确性并不受并发的输入的影响。这里默认的一个隐含条件也是程序是多线程或者多进程的对输入进行处理,但是数据是共享的(内存或者磁盘上)。

3. 分布式系统的可线性化(线性一致性)

如果把线性化扩招到一个分布式系统的话,那么可以这样描述这个系统

  1. 这个系统提供的所有操作都可以看做是原子的,操作通过重排以后能够得到一个(invoke,respone)构成的序列。而且这个序列的执行结果和预期的是一样的。
  2. 系统可以视为只有一个副本。(在上面的并行处理器系统中并没有强调这一点,因为一般认为主存或者硬盘存储只有一个,但是在分布式系统中一般是多台计算机通过网络通信,并不进行存储的共享)

所以在分布式系统中设计一个可线性化的系统更加困难,因为不仅要考虑到操作的原子性保证,还有多个副本(在数据可能不一致的情况下)如何保证向外提供的的数据也满足线性一致性。

主要参考
https://stackoverflow.com/questions/9762101/what-is-linearizability

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值