chrome浏览器源码刨析

chrome中C++锁和条件变量的应用

2021-04-08  本文已影响0人  chrome知识店铺

您真的需要Locking或CondVars吗?

您确定需要使用显式锁定和条件变量吗?在Chrome代码中,消息传递更为常见(通过TaskRunner和PostTask),并且低效率(例如锁和条件变量)仅应在必要时使用。

一些额外的原因:

条件变量几乎不可能在Windows XP或更早版本上正确实现。我们的实现是正确的,但是很慢。每当您使用锁时,都会严重损害我们在Windows上的性能。

很多时候人们只是想等待布尔值。在这种情况下,如果无法传递消息,请改用WaitableEvent。

但是在确实需要使用锁和条件变量的时候,本文将解释使用锁的最佳实践和陷阱。

使用Lock和ConditionVariable

术语和基础

锁类实现了MUT UALclusion锁或互斥的简称。互斥锁用于一次仅允许一个线程独占访问某些资源,该资源通常是某些变量或数据结构。互斥体是如此普遍,以至于创造了许多单词来描述它们的操作。

每个Lock mu具有两个基本操作:mu.Acquire()和mu.Release()。从概念上讲,它只有一点抽象状态:布尔值mu.held。创建mu时,mu.held为false,并且mu被称为freeunlocked。mu.Acquire()阻止调用者,直到mu空闲的那一刻,然后将mu.held的原子性从false更改为true;然后,说mu被呼叫者保持锁定。mu.Release()再次将mu.held设置为false。

调用mu.Acquire()通常称为锁定获取mu,而调用mu.Release()则称为解锁释放mu。线程在按住mu时执行的动作被称为在mu执行。在mu及其不变式下操作的数据结构据说mu保护

Lock的客户必须遵守以下规则:

线程每次获取mu时都必须稍后释放mu。

除非持有mu,否则线程可能不会尝试释放mu。

因为mu.Acquire()原子地操作以更改mu.held的状态,所以我们保证,如果遵循这些规则,则在任何给定时间只能有一个线程保存mu。

最好遵循这些规则,然后在同一过程中将代码区域与对mu.Acquire()和mu.Release()的匹配调用括起来。例如部分称为临界区关键部分,或偶尔监视器霍尔监视器,从该互斥推导后。(Hoare监视器是一种抽象,它会自动获取进入的锁定并在退出时释放它。)在Chrome C ++代码中,许多人都使用成语AutoLock l(mu),获取mu并在l超出范围时释放它。(通常,也可以使用自动解锁l(μ)。)

互斥体在并发编程中执行两项任务。它们的主要作用是保护由多个线程操纵的变量和数据结构的不变量(这些不变量通常称为监视不变量,再次唤起了Hoare的工作)。要求程序员在释放互斥锁之前建立不变式。然后,他可以在获取互斥量时假定不变式成立,即使他的更新在关键部分暂时使不变式无效。不能保证不持有互斥锁的线程中的不变量为真,因为互斥锁持有者此时可能正在更改监视状态。例如,假设Lock mu保护a + b == 0的不变式。那么此代码是合法的:

mu.Acquire();

  断言(a + b == 0); //假定成立的不变量

  a ++; //不变式暂时无效

  b--; //在mu释放之前恢复不变式

  mu.Release();

虽然此代码是错误的:

mu.Acquire();

  断言(a + b == 0); //假定成立的不变量

  a ++; //不变式无效

  mu.Release(); // BUG:亩释放,而不变无效

  mu.Acquire();

  b--; //尝试恢复不变式,但损坏已完成

  mu.Release();

以下内容不会使不变式无效,但会错误地假定未持有锁时它是正确的:

mu.Acquire();

  断言(a + b == 0); //正确:假定保持不变

  mu.Release();

  断言(a + b == 0); //错误:不能假设没有锁不变

仅当对单个关键部分中观察到的状态进行评估时,不变量才成立:

mu.Acquire();

  断言(a + b == 0); //正确:假定保持不变

  temp = a; //取得“ a”的临时副本

  mu.Release();

  mu.Acquire();

  断言(temp + b == 0); //错误:不能假定状态不变

                        //来自两个不同的关键部分

  mu.Release();

互斥锁的一个不太明显的作用是确保在具有顺序不一致的内存系统的机器上顺序地查看此类数据结构。互斥体还可以防止编译器重新排序,否则可能导致竞争状况。

锁的其他属性

调用mu.Try()返回false或获取mu并返回true。它不会阻止。如果mu是空闲的,则不太可能返回false。

如果调用线程不持有mu,则调用mu.AssertAcquired()会以调试模式中止该进程。

锁能够同步其自身的删除。例如,如果对象* o包含一个Lock mu和一个正确维护的引用计数c,则此代码是安全的:

  o-> mu.Acquire();

  bool del =(-(o-> c)== 0);

  o->mu.Release();

  如果(del){删除o; }

并非所有的锁实现都具有此属性,因此在移植代码时不应将其视为理所当然。(要提供此属性,我们保证mu.Release()在mu变为自由的原子时刻之后不会接触mu。)

锁不是可重入的(也称为不可递归)。见下文

条件变量

条件变量是在满足某些条件之前阻塞线程的一种方法。孤立地看,条件变量允许线程阻塞并被其他线程唤醒。但是,条件变量被设计为以特定方式使用。条件变量与互斥变量进行交互,使得在互斥变量保护的状态上轻松等待任意条件。Chrome的C ++条件变量的类型为ConditionVariable。

假设线程要等待某个布尔表达式cond_expr变为true,其中与cond_expr相关的状态由Lock mu保护。程序员会写:

// 服务员

  mu.Acquire();

  while(!cond_expr){

    cv.Wait(); // mu传递给cv的构造函数

  }

  // cond_expr现在成立

  ...

  mu.Release();

Wait()调用以原子方式解锁mu(该线程必须保留该解锁),并在条件变量cv上阻塞。当另一个线程发出条件变量信号时,该线程将重新获取mu,并绕过强制的while循环以重新检查cond_expr

使cond_expr为true的另一个线程可能会执行:

// Waker

  mu.Acquire();

  Make_cond_expr_True();

  // cond_expr现在成立

  cv.Signal();

  mu.Release();

对Signal()的调用会唤醒至少一个等待cv的线程。在任何给定时间,许多线程可能会在条件变量上被阻塞;如果唤醒多个线程有意义,则可以使用Broadcast()。(但是,如果所有正在等待的线程都使用相同的锁,则可能导致争用和性能下降;从Wait()中获取大量线程的一种更好的方法是让每个线程(退出Wait()时)调用Signal( )释放另一个Wait()ing线程。)

等待不同条件的线程可以使用单个条件变量。但是,在这种情况下,任何条件变为真时都必须使用Broadcast(),因为ConditionVariable实现无法以其他方式保证唤醒正确的线程。对每个不同的条件使用一个条件变量会更有效;一个互斥锁可以使用任意数量的条件变量。

如果没有线程需要唤醒,Signal()和Broadcast()都是高效的。(待办事项:验证此条件)客户应使条件成立的关键部分内调用Signal()或Broadcast()。

调用TimedWait()允许线程等待直到条件为真或经过一段时间为止。与Wait()一样,TimedWait()始终在返回之前重新获取互斥量。使用示例:

静态常量int64 kMSToWait = 1000; //我们最多等待1000毫秒

  TimeDelta left_to_wait = TimeDelta :: FromMilliseconds(kMSToWait); // ms在任何给定时间等待

  时间截止期限=时间:: Now()+ left_to_wait;

  mu.Acquire();

  while(!cond_expr && left_to_wait.InMilliseconds()> 0){

    cv.TimedWait(left_to_wait);

    left_to_wait =截止日期-时间:: Now();

  }

  如果(cond_expr){

    // cond_expr为真

  } 别的 {

    // cond_expr false; 1000ms超时已过期

  }

  mu.Release();

推荐用法

由多个线程访问并由至少一个线程写入的变量应始终受锁保护。

此规则的一个例外是,可以在没有互斥量的构造函数中初始化字段,因为那时没有其他线程可以引用该内存。

推荐评论约定

使用线程和互斥锁有两个主要危险:死锁和数据争用。可以使用一些简单的规则并在变量和过程声明中添加一些小注释来避免这些情况。强烈建议您使用此类注释;他们看起来很乏味,但是在避免错误方面有很大帮助。下面显示的特定注释约定来自Nelson和Manasse的工作。

关键部分几乎应该始终在同一例程中开始和结束。也就是说,如果例程获取了一个锁,则应在其返回之前释放它,并且不应释放它本身未获取的锁。通常,这可以通过成对编写Acquire()和Release()调用,或者使用自动锁定l(mu)来实现,当l超出范围时,自动锁定l(mu)会自动释放mu。

每个共享变量/字段都应有一个注释,指示哪个互斥锁对其进行保护:

    int accesses_; //访问次数(由mu_保护)

或解释为什么不需要互斥的注释:

    int table_size_; // 不。表中元素的数量(init之后为只读)

每个互斥锁都应有一个注释,指示它保护哪些变量以及任何非显而易见的不变量:

    Lock mu_; //保护accesses_,list_,count_

                      //不变量:count_ ==链表list_中的元素数

将变量和互斥体上的匹配注释视为类似于过程参数和参数上的匹配类型;冗余对于以后的代码维护者很有帮助。

每当线程可以同时容纳两个互斥量时,应在注释之前(或之后)获取其中一个(或两个)互斥量,以指示必须首先获取哪个互斥量:

    Lock mu0_; //保护foo_(在mu1_之前获取)

如果互斥锁获取顺序不一致,则

可能导致死锁

每个例程都应有一个注释,指示哪些互斥对象在进入时必须保留,也不得保留。这使实现者可以在不检查其调用站点的情况下编辑例程,并允许客户在不读取其主体的情况下使用例程。

调用回调时切勿持有锁,因为该回调可能会再次调用该模块,从而导致死锁。(违反此规则的情况应该非常少见,并在模块的界面中进行明显注释。)注释应指出哪些线程可以或不能用于回调:

    //以“ ms”毫秒为单位调用cb.Run()。

    // cb.Run()在私有线程中被调用;它不会被称为

    //从调用RunAfter()的线程开始,即使ms == 0。

    无效RunAfter(Closure cb,int ms);

在极少数情况下,对于例程而言,获取一个锁并在不释放该锁的情况下返回它,或者释放它没有获取的锁(也许暂时使用ConditionVariable :: Wait)可能很有用。这样的例程可能会使客户端感到惊讶,因此应该在界面中清楚地进行注释。请注意,获取锁并在不释放它的情况下返回的例程实际上是一个锁原语,应这样注释。

每个条件变量都应有一个注释,指示何时发出信号:

    ConditionVariable non_empty_; //当队列变为非空时发出信号

在某些情况下,通过仅从一个线程引用数据来确保对数据的独占访问。(请参阅有关消息传递的部分。)可以将线程视为互斥体的一部分;它可以用作互斥体的一部分。您应该命名线程,并在注释中使用该名称,就好像它是一个锁一样。

    int queue_length_; //接收队列的长度(在net_thread下)

    ...

    //处理队列中的一个数据包

    // L> = net_thread

    void ProcessPacket(){

      ...

    }

在极少数情况下,一个变量可能会受到多个互斥量的保护。这意味着可以在保存任何互斥锁时读取该变量,但是只有在所有互斥锁都被保存时才可以写入该变量。您应该在评论中清楚地记录下来。

    int bytes_left_; //队列中剩余的字节(在mu_和net_thread下)

如果遵循这些约定,则可以通过阅读例程及其注释来直接告诉例程在任何时候持有什么锁。通过阅读有关共享变量和互斥锁的注释,可以知道所有变量访问均已由互斥锁正确保护,并且互斥锁是以正确顺序获取的。

没有这样的注释,使用互斥锁会变得非常困难。我们建议使用它们。

性能提示

关键部分不应太长

通常,您一次应保持互斥锁较短的时间(从纳秒到微秒),并且互斥锁在大多数情况下应该是空闲的,通常接近99%。为此,最好避免在关键部分执行I / O之类的慢速操作-当然,假设互斥锁不是序列化I / O的目的。

另一种更复杂的技术是通过使用更多的锁来使锁更细化,每个锁都保护数据的一个子集。

其他转换可能会有所帮助,例如将关键部分分成两部分,或安排对线程本地的状态执行长时间运行的操作。

锁具收购很便宜,但并非免费

锁获取通常比缓存的变量访问要贵,但比缓存未命中要便宜。如果获取和释放互斥锁的频率太高(例如,每秒超过十万次),则这些操作本身的开销在CPU配置文件中可能会变得很重要。

通过组合关键部分或通过将共享部分缓存在单个线程本地的内存中来延迟对共享状态的操作,可以避免频繁获取。例如,tcmalloc使用每个线程的内存高速缓存来避免锁定每个分配的开销。

陷阱

死锁

活动试图获取已经用尽且无法补充的有限资源时,就会发生死锁(有时称为致命拥抱),除非该活动取得了进展。

当考虑仅涉及互斥锁的死锁时,每个活动通常由一个线程表示,并且资源是互斥锁,互斥锁在持有时会耗尽,而在释放时会得到补充。但是,还存在其他可能性:在消息传递环境中,活动可以由消息表示,资源可以由有界消息队列或有界线程线程池表示。当同时使用互斥锁和消息传递时,死锁可能涉及这些活动和资源的组合。

与互斥锁最简单的死锁是自死锁

mu.Acquire();

  mu.Acquire(); //错误:死锁:线程已经包含mu

涉及两个资源(例如互斥锁和有界大小的线程池)的死锁也很容易生成,但是涉及三个或更多资源的死锁则不太常见。当线程T0在保持M1的同时尝试获取M0的同时,线程T0试图获取M1时,将导致两个互斥锁死;每个线程将无限期地等待另一个线程。

调试死锁

幸运的是,死锁是最容易调试和避免的错误之一。调试通常很容易,因为地址空间恰好在发生错误的位置停止。通常只需要对线程进行堆栈跟踪即可查看线程正在等待什么以及它们拥有哪些资源。(涉及队列中消息的死锁可能很难发现。)

避免死锁

可以通过在资源的消耗前图表中禁止循环来避免死锁。这可以通过在图形上强加一个偏序来完成。如果活动可能会耗尽资源R0,然后尝试使用资源R1可能被耗尽,那么我们说,R0R1(R1和下面的排气前图R0)。为了保证没有死锁,足以保证如果R0R1之前,那么R1从不R0之前。也就是说,对于所有资源对R0和R1,R0和R1都是无序的(都不另一个之前),或者它们的排序是明确的(一个另一个之前,但反之则不)。

仅考虑互斥锁,我们可以通过确保之前获取的图是偏序并因此没有周期来避免死锁。在实践中,我们只需要为同一线程可以同时持有的任何两个互斥对象选择一个顺序,然后使用该选择注释代码。

如所描述的以上,如果一个线程做mu0.Acquire(); mu1.Acquire(); 那么我们应该注释在mu0和mu1的声明之前或之后获得(或两者都获得)。因为我们希望代码是模块化的,所以我们的注释还应指出调用者在进入例程时必须保持或不能保持的锁。这些注释结合在一起,使程序员可以通过获取互斥量或调用例程来知道他是否将违反锁定顺序。经验表明,如果遵循此约定,则死锁通常很少发生并且很容易纠正。

避免死锁的一个特别重要的经验法则是,在调用回调时永远不要保持锁定。更一般而言,请尝试避免长时间持有锁,以及避免对其他您不完全了解的抽象级别的调用。(也就是说,您可能持有对哈希表的访问的锁访问权限,但不应在对复杂子系统的调用中持有锁。)

种族

种族以三种主要方式发生:

访问共享变量时不会受到互斥对象的一致保护。问题的原因将在下面详细讨论。使用已经描述的约定可以避免此错误;只需确保仅在已知持有其保护互斥量的情况下访问每个共享变量即可。这样的比赛可以自动地检测ThreadSanitizer如所描述的下面

关键部分修改了保护状态,但没有保留监视器不变的内容。如果对不变量进行正确注释,则此类错误很少见。

关键部分读取受保护状态,然后将其编码为临时变量或程序计数器。然后释放该锁,然后重新获取该锁,并使用前一个关键节中的状态,尽管该状态仍然有效:

    string name_; //由mu_守护

    size_t NameLen(){自动锁定l(this-> mu_); 返回this-> name_.size(); }

    //需要0 <= i <this-> name_.size()

    int CharFromName(size_t i){AutoLock l(this-> mu_); 返回this-> name_ [i]; }

    ...

    size_t len = this-> NameLen();

    int x = 0;

    如果(len> 0){

      //错误:临时len编码以前的保护状态

      //在另一个内部使用的关键部分。

      //自分配len以来,name_的长度可能已更改。

      x = this-> CharFromName(len-1); 

    }

    ...

这是最隐蔽的种族形式,而避免它们的最著名方法是保持警惕。幸运的是,它们很少见。有一些算法可以使用数据流分析来检测这种竞争,但是到目前为止,还没有一种算法适用于C ++。

调试

Lock和ConditionVariable具有各种有助于调试的功能。

断言

mu.AssertAcquired():如果调用线程未保留mu,则在调试模式下中止。

种族检测

种族检测需要外部工具。这样的工具之一就是ThreadSanitizer,它是一种基于Valgrind二进制翻译框架的动态竞争检测器。有关如何在Chrome中使用它的更多详细信息,请参见此页面

例子

下面显示了使用条件变量的读写器锁和生产者-消费者队列的简单实现。

读写器锁

下面的示例可以以清晰的方式通过各种方式进行改进。特别是,它们允许读者饿死作家。

类别RWLock {

民众:

  RWAcquire():lockers_(0),lock_free _(&mu_){}

  〜RWAcquire(){}

  void WriteAcquire(){//获取写锁

    this-> mu_.Acquire();

    while(this-> lockers_!= 0){

      this-> lock_free_.Wait();

    }

    this-> lockers_ = -1;

    this-> mu_.Release();

  }

  void ReadAcquire(){//获取读锁

    this-> mu_.Acquire();

    while(this-> lockers_ <0){

      this-> lock_free_.Wait();

    }

    this-> lockers _ ++;

    this-> mu_.Release();

  }

  void Release(){//释放锁定(任一模式)

    this-> mu_.Acquire();

    this-> lockers_ =(this-> lockers_ == -1?0:this-> lockers_-1);

    if(this-> lockers_ == 0){//如果锁变为免费,则唤醒所有服务员

      this-> lock_free_.Broadcast();

    }

    this-> mu_.Release();

  }

私人的:

  锁定mu_; //保护储物柜

  int lockers_; // 0 =>空闲,-1 =>作家,+ ve =>读者人数

  条件变量lock_free_; //当lockers_变为0时发出信号

};

生产者-消费者队列

class PCQ {// //有界,阻塞的FIFO生产者-消费者队列

民众:

  PCQ(int n):max_count_(n),non_full _(&mu _),non_empty _(&mu_){}

  〜PCQ(){CHECK_EQ(this-> queue_.size(),0); } //如果队列不为空则报错

  //等待直到队列未满,然后在其末尾添加值

  无效Add(无效*值){

    this-> mu_.Acquire();

    while(this-> queue_.size()== this-> max_count_){

      this-> non_full_.Wait();

    }

    this-> non_empty_.Signal(); //无需广播。

                              //(只有一个卸妆剂可以食用此物品)

                              // 可以用:

                              // if(this-> queue_.size()== 0){this-> non_empty_.Broadcast(); }

    this-> queue_.push_back(value);

    this-> mu_.Release();

  }

  //等待直到此队列为非空,然后删除并返回第一个值

  无效* Remove(){

    this-> mu_.Acquire();

    而(this-> queue_.size()== 0){

      this-> non_empty_.Wait();

    }

    this-> non_full_.Signal(); //无需广播。

                              //(只有一个加法器可以消耗此间隙)

                              // 可以用:

                              // if(this-> queue_.size()== this-> max_count_){this-> non_full_.Broadcast(); }

    无效*值= this-> queue_.front();

    this-> queue_.pop_front();

    this-> mu_.Release();

    返回值;

  }

私人的:

  锁定mu_; //保护* queue_

                        //保护不变量0 <= queue_.size()<= max_count_

  deque <void *>队列_; //排队项目列表

  const int max_count_; // * queue_中的最大项目数(init之后为只读)

  ConditionVariable non_full_; //队列不满时发出信号

  ConditionVariable non_empty_; //当队列变为非空时发出信号

};

为什么互斥锁是它们的样子?

为什么在访问共享数据时使用互斥锁?

访问另一个线程可能正在同时修改的数据非常危险。考虑访问C ++字符串。格式正确的字符串可能具有不变性,例如它的长度字段指示它表示的字符串的真实长度。当发生更新时,此类不变量可能会被字符串实现临时破坏。显然,一个线程可能不允许读取另一个线程正在写入的字符串,因为它可能观察不到与存储的字节一致的长度。如果仅在互斥锁mu下访问该字符串,则该字符串的不变量变为mu的监视器不变量,并且每个线程将看到格式正确的字符串。

诱人的是,当没有明显的监视器不变性要保护时,互斥体是不必要的。考虑类型为double的变量或字段。可能希望能够在没有互斥量保护的情况下从多个线程读取和写入此变量,但这并不安全:

许多机器,包括x86,都不能保证自动访问double。(A栈上分配的双重需求不能由编译器自然对齐,可能导致两个存储器操作单个访问。)因此,我们需要保护的不变量:这双是良好的。

在某些机器上,如果没有互斥锁提供的跨线程同步,似乎不存在明显的数据依赖属性。线程可能会读取格式正确的double值,但会从显然更早的时间获取值。此注释适用于所有类型,包括整数,指针,甚至是语言或运行时提供的“原子”类型

变量的具体类型可能会随着程序的维护而发生变化,而这种变化可能会被typedef隐藏。

简而言之,多个线程访问的数据应使用互斥锁进行保护。否则,就是告诫灾难。

尽管有这样的建议,一些程序员还是会遇到使用低级基元(“原子”类型,比较和交换,内存屏障)而不是互斥锁的智力挑战。这样的代码有很多问题:

它通常不起作用。

总体而言,使用互斥锁通常不会比使用互斥锁更快。

它可能依赖于有关硬件或编译器或两者的假设。

但是,不使用此类代码的最重要原因是它很复杂。即使作者理解这一点,代码的下一个维护者也可能不会。更糟糕的是,他可能认为自己理解。

避免锁定的最佳方法是避免共享的可变状态。当需要共享的可变状态时,请使用互斥锁。如果遇到锁争用,请考虑使用更多的互斥对象,每个互斥对象保护的数据更少(即,使锁定更细粒度)。如果您觉得必须访问不带互斥锁的共享可变变量,并且拥有表明这样做值得维护费用的数据,请咨询专家该如何做。

为什么锁的持有人无法重新获得它?

一些互斥体实现(尤其是Java的互斥体实现)和Windows CRITICAL_SECTION被称为reentrantrecursive。它们允许互斥量持有者通过保持内部获取计数持有者来重新获取互斥量而不会出现死锁身份,而不是持有的布尔值。当计数为零时,互斥锁是免费的。收购计数跟踪持有人进行的收购数量;要求持有人释放互斥锁的次数与获取互斥锁的次数相同,以使互斥锁释放。这种簿记允许对象的某个方法在持有该锁的同时调用同一对象的其他方法,即使这些其他方法获得了该锁。我们不允许在Lock中使用这种看似方便的方法,不是因为维护计数器的额外费用很小,而是由于两个问题。

回想一下,互斥锁的主要目的是允许程序员维护监视器不变式,并且程序员在获取适当的互斥量之后可以假定监视器不变式。考虑一个Java方法M0,它获取一个互斥量mu保护不变量Inv。M0的作者有权在获得亩后立即承担Inv的责任。现在考虑另一种方法M1,它获取mu,在更新过程中使Inv无效,调用M0,恢复Inv并释放mu。M0假定为Inv,但是Inv调用M0时为true,因此M0失败。请记住,由于继承的缘故,M0和M1可能都有多个作者多年来编写的多个实现,并且可能在同一个程序中具有多个实现。M1的作者可能无法使用M0的源代码,反之亦然。没有非常好的纪律和文档标准,程序员可能无法理解为什么M0无法正常运行。

如果程序员尝试使用不可重入的互斥锁(例如Lock)执行相同的操作,则他的代码将立即死锁,并且堆栈跟踪将显示线程正在尝试重新获取它已经持有的锁。不仅可以立即发现错误,而且解决方法通常很简单:编写一个获取mu并调用私有方法M0'的小方法M0,假设Inv但不获取mu ,它可以完成实际工作。M0和M0'的规格仅在锁定方式上有所不同,因此程序员几乎总是记录这种差异,通常使用M0和M0'的名称。附加方法的存在以及相应的名称或注释为M1的作者提供了额外的提示。他意识到,通过调用M0'而不是M0,他有责任建立调用前的Inv ---监视不变式不能自动保证它。此解决方案不是万能药,但不允许重入至少会使错误变得明显,而不是将其隐藏。

再入的第二个问题与条件变量有关。在上面的示例中,假设M0等待条件变量,从而有效地包含多个临界区。通常,M0可以工作,但是如果从M1处调用并保持mu,则不清楚会发生什么,而且两种结果都不令人满意。如果wait()原语将mu的获取计数减1,则mu不会变得空闲,条件永远不会变为真,并且线程会死锁。相反,如果通过Wait()将获取计数设置为零,则在M1启动的关键部分期间mu变为空闲。这可能会导致M1静默故障。在非重入情况下,必须像以前一样将M0分为M0和M0'。由于M0'等待条件变量,因此它现在有一个有趣的规范:它会暂时释放它没有获得的互斥锁。这是不寻常的,通常非常危险,因此可能有人希望对此发表评论。然后,此注释告诉M1的作者,如果他叫M0',则必须格外小心。

为什么不使用受监视的模块?(或自动锁定的对象,锁定的指针,无锁定的数据结构,...)

像在Hoare监视器中一样,通过声明以某种方式在进入模块时获取互斥量并在退出时释放互斥量,从而使互斥量的获取和释放自动化似乎很有吸引力。这可以用于琐碎的情况,但是即使是非常普通的示例也需要更复杂的锁定。

考虑一个表* t将字符串映射到整数计数。该表可能具有方法insert(),lookup(),remove()。如果表提供了自己的同步(也许通过某种机制自动插入了每种方法),我们将消除表本身内的数据争用,但这对客户端没有帮助。考虑下面的代码,它增加了表* t中“ foo”的计数:

int * v = t-> lookup(“ foo”); //安全,因为* t是监视器

  如果(v!= 0){

    (* v)++; //错误:数据竞争:解锁增量

  } 别的 {

    t-> insert(“ foo”,1); //安全,因为* t是监视器

  }

如果客户端不使用自己的互斥锁,则计数可能会丢失。如果他这样做,则* t内部的同步是多余的。因此,受监视的模块很少有用。

SGI STL的实现者也有同样的看法

另一个问题是自动锁定机制的设计人员通常希望减少实现监视器所需的键入量,而不是提高代码的可读性和可维护性。这两个愿望经常冲突。如果可以告知何时释放锁,则某些代码更易读

互斥锁的替代品

有多种处理并发和完全避免并发的方法。在各种可能的模型中,只有两个允许使用多个CPU和资源共享的高性能实现,并且仍然允许从较小的组件构建大型程序,同时保持抽象边界。这些模型是“线程+互斥体+条件变量”和“线程+消息传递”。这两个可以一起使用,并且经常一起使用。

讯息传递

可以将数据与线程相关联,以便每个线程拥有一些变量和数据结构。变量只能由其自己的线程访问。其他希望访问数据的线程随后通过消息传递与所属线程进行通信,例如Chrome的TaskRunner。

这种样式是基于互斥锁的样式的双重形式(请参阅Lauer和Needham在该主题上经常被引用的论文):消息发送对应于获取互斥锁;在关键部分中运行对应于在所属线程中执行代码;接收到答复对应于释放互斥锁。因此,这两种方法之间最明显的区别是,在消息传递过程中,所有处理特定数据项的代码都汇集到一个线程中,而使用互斥锁时,数据访问可以与其他代码交错。

消息传递和互斥可以混合使用;通常一个是首选,要么是因为作者对样式感到满意,要么是因为一个导致了比另一个更清晰的模块。当存在已经将访问序列化的自然资源(例如I / O设备),最好用单个例程表示的线性状态机或关键部分很长时,消息传递模型往往会很好地工作。当关键部分很短并且可以在许多地方调用时,或者当读写器锁可以有效使用时,互斥体可以很好地工作。在Chrome代码中,消息传递更为常见(通过TaskRunner和PostTask),并且仅在必要时使用低级原语(例如锁和条件变量)。

两种模型都可以实现高吞吐量的实现,并且都可能遇到竞争和僵局。通过使用无限制的队列和/或线程池,通常可以在消息传递模型中消除死锁。

原子类型和原子运算

许多运行时和语言(包括C ++ 11)提供原子操作,例如比较和交换,以及可以原子读写的“原子”类型。原子操作和类型比人们最初想像的要难得多,并且不应该在常规代码中使用它们。不幸的是,由于各种原因,程序员被他们吸引了:

程序员喜欢使用这些操作的智力难题。这会导致生成巧妙的但不明智的代码,并且常常会破坏代码。

涉及原子运算的算法非常微妙。例如,一种通用,高效,无锁,单链列表算法需要大量研究才能发现,并且需要谨慎实施。几乎所有程序员在尝试直接使用原子操作时都会犯错。即使他们没有犯错误,结果代码也很难被其他人维护。CPU和编译器都可以以导致微妙的竞争条件的方式重新排列读写。

双重检查锁定的听起来很简单的模式实际上是非常微妙的,并且通常不正确地实现。

程序员认为锁定是昂贵的,并且使用原子操作会更有效。但是实际上,获得和释放锁比缓存未命中要便宜。注意缓存行为通常是提高性能的更有效的方法。此外,无锁数据结构通常比使用锁更昂贵。这是因为锁允许对复杂的数据结构进行任意更改。如果必须在没有锁的情况下进行相同的更改,则结果可能需要更多的原子性的read-modify-write指令,而不是更少。

人们希望在高并发时避免锁争用。最好通过对锁定的数据结构进行分区以避免锁定争用来解决。例如,与使用原子操作来构建一个无锁哈希表相比,从许多具有自己的锁的普通哈希表中构建高并发哈希表更容易,更有效,也更有用

原子操作只能用于少数几个专家编写的低层数据结构中,然后进行彻底的检查和测试。不幸的是,许多人尝试编写无锁代码,几乎总是这是一个错误。请不要落入这个陷阱:不要使用原子操作或类型---如果你这样做,你会犯错误,你将花费公司的时间和金钱。

单线程

仅使用一个线程的进程不需要互斥体,对于不需要高性能或本质上是顺序的简单程序,这通常是最好的方法。但是,对于大型程序或需要高性能的程序,通常不是最佳选择。

单线程应用程序只能使用一个CPU,即使考虑到锁定的开销,这也通常使其比其他选项慢得多。如果应用程序足够简单,则可以在每台计算机上运行同一程序的多个副本,但这会带来两个低效率:跨地址空间上下文切换比线程上下文切换更昂贵,因为线程在地址时共享TLB条目空格不;地址空间的分离会阻止共享某些资源(缓存,端口等)。

某些程序,例如网络服务器,表现出自然的并发性:它们必须处理许多并发的客户端请求,因此需要某种机制来允许这种情况。

线程上下文切换成本的谬误

有人试图说,复用单个线程比使用多个线程便宜得多,因为单个线程不需要线程上下文切换。这样的争论源于对于什么构成“上下文切换”以及什么导致其成本的混淆。上下文切换只是在多个活动之间多路复用处理器的行为。无论是在内核模式还是在用户模式下,其主要成本都是相似的:

当程序切换到新活动时,通过触摸与新活动相关联的数据和指令,将导致缓存和TLB未命中。此成本是最重要的,并且无论新活动是在不同线程中还是在同一线程中发生,成本都基本相同。成本不仅发生在多线程程序执行线程上下文切换时,还发生在事件驱动程序处理新事件时,或者合作多线程程序在用户空间中切换上下文时。由于时间片很大,因此多线程程序很少因时间片而进行上下文切换。

用户内核模式切换的成本有时被计为线程之间上下文切换成本的一部分。然而,多线程程序时,他们通常有上下文切换已经进入其他原因---典型的内核,通过一些系统调用或服务中断。单线程程序会引起这些相同的模式切换,因此它们对于所有模型都是通用的。可能希望互斥量和条件变量调用会增加系统调用的数量,但是这种效果是适度的,因为无竞争的互斥量不会引起系统调用,而竞争的互斥量和条件变量操作应该相对较少。

地址空间之间的上下文切换更加昂贵,因为必须替换TLB条目。

总而言之,如果使用单个地址空间,则在活动之间进行切换的成本几乎与该地址空间中使用的线程数无关。导致执行最慢的技术是运行单线程程序的多个副本。

事件驱动风格

为了在单个线程中处理并发活动,一些程序员采用了一种称为事件驱动状态机连续传递的样式:可以将给定请求的动作分解为一个处理程序例程图,该处理程序例程永远不会阻塞I /在内部进行O操作,而是指定在待处理的I / O请求完成时应调用哪些处理程序。这种方法可以使之起作用,甚至在简单的程序中也可能很简单,但是它对可读性,模块化和抽象性以及仅使用一个CPU都有不良影响。若要查看事件驱动样式的问题,请考虑以下代码

...

  for(int i = 0; i!= 10; i ++){foo(i); }

  ...

现在假设第三方库例程foo()在将来的某个时间被修改以改善其功能或平均性能。想象一下,这种改进的副作用是foo()有时必须执行某些不应在处理程序中执行的阻塞I / O。for循环的作者或foo()的新实现的作者都没有做任何异常的事情,但是该程序在事件驱动的环境中可能显示出较差的吞吐量,甚至出现死锁。即使是微妙的变化也会产生不良影响。假设foo()包含对fprintf()的调用;如果有一天将输出流重定向到具有高吞吐量但高延迟的设备,则程序的吞吐量将急剧下降,因为在不重写fprintf()和foo的情况下,无法在事件驱动的模型中隐藏foo()的延迟。 ()。

如果我们更改foo()的签名以包含在foo()完成时要调用的延续,则可以解决性能问题。但是,这不是局部更改:必须重新构造循环,以便将循环变量编码为连续状态。更糟糕的是,对foo()的每次使用都必须进行类似的修改,不仅要处理新的签名,还要将调用过程分成两个部分:-调用foo()之前的前缀和延续。因此,foo()中的局部更改已在很大程度上影响了任意数量的模块:事件驱动的样式无法保留模块化。

请注意,这与多线程情况有何不同。就像事件驱动样式要求foo()必须是非阻塞的一样,多线程样式也需要foo()必须是线程安全的。但是,此约束更容易使用。首先,大多数库无论是自然生成还是设计上都是线程安全的,而只有少数几本设计用于事件驱动的系统。(例如,fprintf中()是线程安全的,但不提供回调机制。)其次,如果FOO()是不是线程安全的,调用它可以制成安全或者通过更改为foo()通过包装FOO()带有一个新例程foo(),该例程在调用旧foo()之前获取了一个锁。更改都是本地的,不会影响接口的其他方面,因此保留了模块化。

对于诸如printf()之类的签名不能轻易更改的例程,事件驱动样式的问题更为严重。更令人担忧的是,即使对整个程序进行任意重组,也无法在单线程事件驱动的系统中提高某些I / O方法的效率。例如,尽管可以努力写出printf()的连续传递等效项,但内存映射的I / O和已编程的I / O却没有这样的等效项。

事件驱动样式的另一个问题是,生成的代码变得非常难以理解,维护和调试。这主要是因为从阅读代码中很难分辨出哪个例程导致当前例程被调用,以及哪个例程将被下一步调用。标准的调试和性能工具也变得不太有效,因为它们很少支持通过连续性进行跟踪,并且连续性机制很少维护呼叫历史记录。相反,非事件驱动程序在线程堆栈上方便地维护了它们的大部分状态和历史记录。调试工具仅通过显示堆栈跟踪就可以揭示很多内容。

协作多线程

一种称为协作多线程的替代样式允许程序员在单个CPU上使用多个线程。调度程序保证没有两个线程可以同时运行,并且保证永远不会抢占尚未阻塞的线程。从理论上讲,这可以省略互斥体:代码a ++; b--; 将始终原子执行。不幸的是,对这个属性的依赖使代码更加脆弱。例如,因为任何I / O都可能阻塞,所以a ++; printf(“ bar \ n”); b--; 可能不会自动执行,并且a ++; foo(); b--; 原子执行或不原子执行,具体取决于foo()的实现。因此,在没有显式同步的情况下进行协作式多线程处理可能会导致其中通过在另一个模块中添加调试printf语句将错误引入一个模块的代码。如果使用显式同步,

由于这些原因,除非程序非常简单,否则它通常会在性能和可维护性上付出代价以使用多个线程,并使用互斥锁显式保护共享变量或在线程之间通过消息进行通信。

为什么cv.Wait()总是处于while循环中?

Hoare的原始条件变量不需要while循环,但是现代版本出于一些微妙的原因需要它:

while循环的存在使您可以通过本地检查来判断退出循环时该条件为真。Hoare最初的精确语义要求检查所有可能调用Signal()的代码,这使某些错误很难调试。

while循环允许客户进行虚假唤醒,这使程序员有机会为简化编程而牺牲性能。假设他安排线程在修改保护状态时始终向状态变量发出信号,而不是仅在使特定条件为真时发出信号。这样就可以在等待者和唤醒者之间实现模块化:唤醒者不需要知道唤醒者正在等待什么条件,并且每个等待者都可以等待不同的条件而不会影响唤醒者的代码。

while循环使条件变量实现可以更自由地以任何顺序调度唤醒线程。考虑一个唤醒正在等待条件C的线程T1的线程T0。如果运行时语义保证T1接下来将进入关键部分,则T1可以假定C。但是上下文切换有开销,因此通常仅添加T1效率更高。进入运行队列,同时继续运行T0和其他线程,然后它们可能会在T1之前进入关键部分。如果这些线程中的任何一个伪造了C,则T1进入关键部分时不得假定C;否则,T1不能使用C。调度使它似乎收到了虚假的唤醒。while循环确保T1测试C,并且仅在C确实为真时才继续。因此,while循环有效地为选择有效的调度规则提供了更大的自由度。

定时等待变得不那么容易出错。定时等待可能导致线程在其条件C为真之前唤醒。假设程序员忘记测试超时。如果他被迫使用while循环,则他的线程将再次进入睡眠状态,并且程序可能会死锁,从而可以轻松检测到该错误。没有while循环,线程将错误地采用C,并导致任意不良行为。

为什么与cv.Wait()一起使用的条件必须是受互斥量保护的状态函数?

考虑一个线程W,等待条件cond_expr为真:

mu.Acquire();

  while(!cond_expr){

    cv.Wait(); // mu传递给cv的构造函数

  }

  // cond_expr现在成立

  ...

  mu.Release();

如果cond_expr不是由mu保护的状态函数,则可能发生两件事:

假设线程W发现cond_expr为false,并且即将调用cv.Wait()。如果与cond_expr关联的状态不受mu保护,则另一个线程可以使cond_expr为true并在W调用cv.Wait()之前调用cv.Signal()。这意味着即使cond_expr为true,W仍可能在Wait()中无限期地阻塞(仅通过调用Signal()才唤醒Wait()中当前存在的线程)。

假设线程W发现cond_expr为true,并且即将执行标有“ cond_expr现在保存”的代码。如果与cond_expr关联的状态不受mu保护,则另一个线程可以在W运行其余代码之前将cond_expr设置为false,因此W无法假定cond_expr。这否定了条件变量的目的,该条件变量是要为W提供有关cond_expr的保证。

为什么将Signal()放在关键部分内?

在大多数情况下,将Signal()放在关键部分之后是正确的,但是在Chrome代码中,将其放入关键部分始终既安全又有效。(待办事项:验证此)

一些文本建议将Signal()放在关键部分的后面,因为当线程尝试从Wait()返回时尝试重新获取互斥锁时,互斥锁将很可能是空闲的。如果Signal()在临界区之内,那么一个幼稚的实现可能会唤醒线程,然后该线程可能再次在唤醒它的线程所持有的互斥体上进行阻塞。

Chrome的条件变量(以及最合理的实现)会检测到这种情况,并延迟唤醒等待的线程,直到互斥体释放为止。(TODO:对此进行验证)因此,在关键部分内调用Signal()不会降低性能。

在极少数情况下,在关键部分之后调用Signal()是不正确的,因此我们建议始终在关键部分内使用它。以下代码可以在删除条件变量后尝试访问该条件变量,但是如果在关键部分内部调用Signal()则可能是安全的。

struct Foo {Lock mu; ConditionVariable cv; 布尔德尔 ...};

  ...

  无效Thread1(Foo * foo){

    foo-> mu.Acquire();

    while(!foo-> del){

      foo-> cv.Wait();

    }

    foo-> mu.Release();

    删除foo;

  }

  ...

  无效Thread2(Foo * foo){

    foo-> mu.Acquire();

    foo-> del = true;

                          // Signal()应该在这里调用

    foo-> mu.Release();

    foo-> cv.Signal(); //错误:foo可能已被删除

  }

互斥量的实现者为什么要在竞争下关注互斥量的性能?

客户应避免锁争用,因为争用必然意味着较少的并行性。一些线程被阻塞,而另一个线程执行关键部分。由于客户端必须避免争用,因此某些互斥量的实现者较少关注争用下互斥量的性能。但是,尽管客户尽了最大努力,有时还是会遇到争执。例如:

网络服务器可能会变得超载或使用模式发生更改,从而导致互斥量的使用率超过正常水平。

程序可能在具有更多CPU的升级计算机上运行,从而导致以前轻载的互斥锁发生争用。

软件开发人员鼓励在程序的各个部分之间进行抽象,因此,模块的作者和客户对模块的使用方式可能会有不同的期望。特别是,客户端可能会引起他不知道的互斥锁上的争用。

尽管对客户来说,解决争用以避免并行性的损失很重要,但是并行性的损失应该是他们的主要考虑因素。即使相互竞争激烈,互斥体本身的性能也不会急剧下降。也就是说:如果负载再次下降,则过载的服务器应从过载中恢复;CPU数量多的计算机的运行速度不应低于CPU数量少的计算机的运行速度;并且更频繁地调用模块不应减少完成的工作量,即使它不会增加工作量。

理想情况下,关键部分应为多个竞争线程提供与单个线程大致相同的进度。互斥量实现可以通过不提供公平性以及防止多个已经阻塞的线程同时竞争锁来接近此理想状态。

每个Lock操作是否都意味着存在内存障碍?

程序员不应将锁定操作用作在其代码中插入任意内存屏障的一种方式。(或者用于控制线程何时运行。)锁定操作仅意味着为了保护监视器不变量而必需的顺序。特别是,目的是:

如果线程T 0和T 1执行以下代码,则其中某些位置由T 0 _Inside()和T 1 _Inside()中的一个修改,并由另一个读取或写入:

      //线程T 0            //线程T 1

        T 0 _Before(); T 1 _Before();

        mu.Acquire(); mu.Acquire();

        T 0 _Inside(); T 1 _Inside();

        mu.Release();          mu.Release();

        T 0 _After(); T 1 _After();

然后在T中的存储器操作X _Before()和T X _Inside()的所有先于T中的存储器操作Ý _Inside()和T Ý _After()要么对于x = 0,Y = 1或X = 1,Y = 0。

如果该谓词不成立,则不应从Lock操作假定任何内存排序。令没有预期优化的最简单实现的程序员感到惊讶。不幸的是,一些API标准增强了这种期望。

我们不赞成这种假设,因为它们使各种转换变得更加困难。示例包括:

删除与他人多余的关键部分。

删除仅一个线程使用的锁,或不保护任何数据的锁。

锁和关键部分的合并和拆分。

排他锁到共享锁的转换。

用事务性内存代替锁。

一些锁实现已经应用了其中的某些转换,因此效率更高。因此,Lock保留在安全的情况下使用此类转换的权利,即使这意味着消除内存障碍。

上一篇下一篇

猜你喜欢

热点阅读