并行计算的多线程数据结构

原链接  http://www.ibm.com/developerworks/cn/aix/library/au-multithreaded_structures1/index.html

              http://www.ibm.com/developerworks/cn/aix/library/au-multithreaded_structures2/index.html

简介

现在,您的计算机有四个 CPU 核;并行计算 是最时髦的主题,您急于掌握这种技术。但是,并行编程不只是在随便什么函数和方法中使用互斥锁和条件变量。C++ 开发人员必须掌握的关键技能之一是设计并发数据结构。本文是两篇系列文章的第一篇,讨论如何在多线程环境中设计并发数据结构。对于本文,我们使用 POSIX Threads 库(也称为 Pthreads;见 参考资料 中的链接),但是也可以使用 Boost Threads 等实现(见 参考资料 中的链接)。

本文假设您基本了解基本数据结构,比较熟悉 POSIX Threads 库。您还应该基本了解线程的创建、互斥锁和条件变量。在本文的示例中,会相当频繁地使用 pthread_mutex_lockpthread_mutex_unlockpthread_cond_waitpthread_cond_signal 和pthread_cond_broadcast

设计并发队列

我们首先扩展最基本的数据结构之一:队列。我们的队列基于链表。底层列表的接口基于 Standard Template Library(STL;见 参考资料)。多个控制线程可以同时在队列中添加数据或删除数据,所以需要用互斥锁对象管理同步。队列类的构造函数和析构函数负责创建和销毁互斥锁,见 清单 1

清单 1. 基于链表和互斥锁的并发队列
#include <pthread.h>
#include <list.h> // you could use std::list or your implementation 

namespace concurrent { 
template <typename T>
class Queue { 
public: 
   Queue( ) { 
       pthread_mutex_init(&_lock, NULL); 
    } 
    ~Queue( ) { 
       pthread_mutex_destroy(&_lock);
    } 
    void push(const T& data);
    T pop( ); 
private: 
    list<T> _list; 
    pthread_mutex_t _lock;
}

};

在并发队列中插入和删除数据

显然,把数据放到队列中就像是把数据添加到列表中,必须使用互斥锁保护这个操作。但是,如果多个线程都试图把数据添加到队列中,会发生什么?第一个线程锁住互斥并把数据添加到队列中,而其他线程等待轮到它们操作。第一个线程解锁/释放互斥锁之后,操作系统决定接下来让哪个线程在队列中添加数据。通常,在没有实时优先级线程的 Linux® 系统中,接下来唤醒等待时间最长的线程,它获得锁并把数据添加到队列中。清单 2 给出代码的第一个有效版本。

清单 2. 在队列中添加数据
void Queue<T>::push(const T& value ) { 
       pthread_mutex_lock(&_lock);
       _list.push_back(value);
       pthread_mutex_unlock(&_lock);
}

取出数据的代码与此类似,见 清单 3

清单 3. 从队列中取出数据
T Queue<T>::pop( ) { 
       if (_list.empty( )) { 
           throw ”element not found”;
       }
       pthread_mutex_lock(&_lock); 
       T _temp = _list.front( );
       _list.pop_front( );
       pthread_mutex_unlock(&_lock);
       return _temp;
}

清单 2 和 清单 3 中的代码是有效的。但是,请考虑这样的情况:您有一个很长的队列(可能包含超过 100,000 个元素),而且在代码执行期间的某个时候,从队列中读取数据的线程远远多于添加数据的线程。因为添加和取出数据操作使用相同的互斥锁,所以读取数据的速度会影响写数据的线程访问锁。那么,使用两个锁怎么样?一个锁用于读取操作,另一个用于写操作。清单 4 给出修改后的 Queue 类。

清单 4. 对于读和写操作使用单独的互斥锁的并发队列
template <typename T>
class Queue { 
public: 
   Queue( ) { 
       pthread_mutex_init(&_rlock, NULL); 
       pthread_mutex_init(&_wlock, NULL);
    } 
    ~Queue( ) { 
       pthread_mutex_destroy(&_rlock);
       pthread_mutex_destroy(&_wlock);
    } 
    void push(const T& data);
    T pop( ); 
private: 
    list<T> _list; 
    pthread_mutex_t _rlock, _wlock;
}

清单 5 给出 push/pop 方法的定义。

清单 5. 使用单独互斥锁的并发队列 Push/Pop 操作
void Queue<T>::push(const T& value ) { 
       pthread_mutex_lock(&_wlock);
       _list.push_back(value);
       pthread_mutex_unlock(&_wlock);
}

T Queue<T>::pop( ) { 
       if (_list.empty( )) { 
           throw ”element not found”;
       }
       pthread_mutex_lock(&_rlock);
       T _temp = _list.front( );
       _list.pop_front( );
       pthread_mutex_unlock(&_rlock);
       return _temp;
}

设计并发阻塞队列

目前,如果读线程试图从没有数据的队列读取数据,仅仅会抛出异常并继续执行。但是,这种做法不总是我们想要的,读线程很可能希望等待(即阻塞自身),直到有数据可用时为止。这种队列称为阻塞的队列。如何让读线程在发现队列是空的之后等待?一种做法是定期轮询队列。但是,因为这种做法不保证队列中有数据可用,它可能会导致浪费大量 CPU 周期。推荐的方法是使用条件变量 — 即 pthread_cond_t 类型的变量。在深入讨论语义之前,先来看一下修改后的队列定义,见 清单 6

清单 6. 使用条件变量的并发阻塞队列
template <typename T>
class BlockingQueue { 
public: 
   BlockingQueue ( ) { 
       pthread_mutex_init(&_lock, NULL); 
       pthread_cond_init(&_cond, NULL);
    } 
    ~BlockingQueue ( ) { 
       pthread_mutex_destroy(&_lock);
       pthread_cond_destroy(&_cond);
    } 
    void push(const T& data);
    T pop( ); 
private: 
    list<T> _list; 
    pthread_mutex_t _lock;
    pthread_cond_t _cond;
}

清单 7 给出阻塞队列的 pop 操作定义。

清单 7. 从队列中取出数据
T BlockingQueue<T>::pop( ) { 
       pthread_mutex_lock(&_lock);
       if (_list.empty( )) { 
           pthread_cond_wait(&_cond, &_lock) ;
       }
       T _temp = _list.front( );
       _list.pop_front( );
       pthread_mutex_unlock(&_lock);
       return _temp;
}

当队列是空的时候,读线程现在并不抛出异常,而是在条件变量上阻塞自身。pthread_cond_wait 还隐式地释放 mutex_lock。现在,考虑这个场景:有两个读线程和一个空的队列。第一个读线程锁住互斥锁,发现队列是空的,然后在 _cond 上阻塞自身,这会隐式地释放互斥锁。第二个读线程经历同样的过程。因此,最后两个读线程都等待条件变量,互斥锁没有被锁住。

现在,看一下 push() 方法的定义,见 清单 8

清单 8. 在阻塞队列中添加数据
void BlockingQueue <T>::push(const T& value ) { 
       pthread_mutex_lock(&_lock);
       const bool was_empty = _list.empty( );
       _list.push_back(value);
       pthread_mutex_unlock(&_lock);
       if (was_empty) 
           pthread_cond_broadcast(&_cond);
}

如果列表原来是空的,就调用 pthread_cond_broadcast 以宣告列表中已经添加了数据。这么做会唤醒所有等待条件变量 _cond 的读线程;读线程现在隐式地争夺互斥锁。操作系统调度程序决定哪个线程获得对互斥锁的控制权 — 通常,等待时间最长的读线程先读取数据。

并发阻塞队列设计有两个要注意的方面:

  • 可以不使用 pthread_cond_broadcast,而是使用 pthread_cond_signal。但是,pthread_cond_signal 会释放至少一个等待条件变量的线程,这个线程不一定是等待时间最长的读线程。尽管使用 pthread_cond_signal 不会损害阻塞队列的功能,但是这可能会导致某些读线程的等待时间过长。
  • 可能会出现虚假的线程唤醒。因此,在唤醒读线程之后,要确认列表非空,然后再继续处理。清单 9 给出稍加修改的 pop() 方法,强烈建议使用基于 while 循环的 pop() 版本。
清单 9. 能够应付虚假唤醒的 pop() 方法
T BlockingQueue<T>::pop( ) { 
       pthread_cond_wait(&_cond, &_lock) ;
       while(_list.empty( )) { 
           pthread_cond_wait(&_cond) ;
       }
       T _temp = _list.front( );
       _list.pop_front( );
       pthread_mutex_unlock(&_lock);
       return _temp;
}

设计有超时限制的并发阻塞队列

在许多系统中,如果无法在特定的时间段内处理新数据,就根本不处理数据了。例如,新闻频道的自动收报机显示来自金融交易所的实时股票行情,它每 n 秒收到一次新数据。如果在 n 秒内无法处理以前的一些数据,就应该丢弃这些数据并显示最新的信息。根据这个概念,我们来看看如何给并发队列的添加和取出操作增加超时限制。这意味着,如果系统无法在指定的时间限制内执行添加和取出操作,就应该根本不执行操作。清单 10 给出接口。

清单 10. 添加和取出操作有时间限制的并发队列
template <typename T>
class TimedBlockingQueue { 
public: 
   TimedBlockingQueue ( );
    ~TimedBlockingQueue ( );
    bool push(const T& data, const int seconds);
    T pop(const int seconds); 
private: 
    list<T> _list; 
    pthread_mutex_t _lock;
    pthread_cond_t _cond;
}

首先看看有时间限制的 push() 方法。push() 方法不依赖于任何条件变量,所以没有额外的等待。造成延迟的惟一原因是写线程太多,要等待很长时间才能获得锁。那么,为什么不提高写线程的优先级?原因是,如果所有写线程的优先级都提高了,这并不能解决问题。相反,应该考虑创建少数几个调度优先级高的写线程,把应该确保添加到队列中的数据交给这些线程。清单 11 给出代码。

清单 11. 把数据添加到阻塞队列中,具有超时限制
bool TimedBlockingQueue <T>::push(const T& data, const int seconds) {
       struct timespec ts1, ts2;
       const bool was_empty = _list.empty( );
       clock_gettime(CLOCK_REALTIME, &ts1);
       pthread_mutex_lock(&_lock);
       clock_gettime(CLOCK_REALTIME, &ts2);
       if ((ts2.tv_sec – ts1.tv_sec) <seconds) {
       was_empty = _list.empty( );
       _list.push_back(value);
       {
       pthread_mutex_unlock(&_lock);
       if (was_empty) 
           pthread_cond_broadcast(&_cond);
}

clock_gettime 例程返回一个 timespec 结构,它是系统纪元以来经过的时间(更多信息见 参考资料)。在获取互斥锁之前和之后各调用这个例程一次,从而根据经过的时间决定是否需要进一步处理。

具有超时限制的取出数据操作比添加数据复杂;注意,读线程会等待条件变量。第一个检查与 push() 相似。如果在读线程能够获得互斥锁之前发生了超时,那么不需要进行处理。接下来,读线程需要确保(这是第二个检查)它等待条件变量的时间不超过指定的超时时间。如果到超时时间段结束时还没有被唤醒,读线程需要唤醒自身并释放互斥锁。

有了这些背景知识,我们来看看 pthread_cond_timedwait 函数,这个函数用于进行第二个检查。这个函数与 pthread_cond_wait 相似,但是第三个参数是绝对时间值,到达这个时间时读线程自愿放弃等待。如果在超时之前读线程被唤醒,pthread_cond_timedwait 的返回值是0清单 12 给出代码。

清单 12. 从阻塞队列中取出数据,具有超时限制
T TimedBlockingQueue <T>::pop(const int seconds) { 
       struct timespec ts1, ts2; 
       clock_gettime(CLOCK_REALTIME, &ts1); 
       pthread_mutex_lock(&_lock);
       clock_gettime(CLOCK_REALTIME, &ts2);

       // First Check 
       if ((ts1.tv_sec – ts2.tv_sec) < seconds) { 
           ts2.tv_sec += seconds; // specify wake up time
           while(_list.empty( ) && (result == 0)) { 
               result = pthread_cond_timedwait(&_cond, &_lock, &ts2) ;
           }
           if (result == 0) { // Second Check 
               T _temp = _list.front( );
              _list.pop_front( );
              pthread_mutex_unlock(&_lock);
              return _temp;
          }
      }
      pthread_mutex_unlock(&lock);
      throw “timeout happened”;
}

清单 12 中的 while 循环确保正确地处理虚假的唤醒。最后,在某些 Linux 系统上,clock_gettime 可能是 librt.so 的组成部分,可能需要在编译器命令行中添加 –lrt 开关。

使用 pthread_mutex_timedlock API

清单 11 和 清单 12 的缺点之一是,当线程最终获得锁时,可能已经超时了。因此,它只能释放锁。如果系统支持的话,可以使用pthread_mutex_timedlock API 进一步优化这个场景(见 参考资料)。这个例程有两个参数,第二个参数是绝对时间值。如果在到达这个时间时还无法获得锁,例程会返回且状态码非零。因此,使用这个例程可以减少系统中等待的线程数量。下面是这个例程的声明:

int pthread_mutex_timedlock(pthread_mutex_t *mutex,
       const struct timespec *abs_timeout);

设计有大小限制的并发阻塞队列

最后,讨论有大小限制的并发阻塞队列。这种队列与并发阻塞队列相似,但是对队列的大小有限制。在许多内存有限的嵌入式系统中,确实需要有大小限制的队列。

对于阻塞队列,只有读线程需要在队列中没有数据时等待。对于有大小限制的阻塞队列,如果队列满了,写线程也需要等待。这种队列的外部接口与阻塞队列相似,见 清单 13。(注意,这里使用向量而不是列表。如果愿意,可以使用基本的 C/C++ 数组并把它初始化为指定的大小。)

清单 13. 有大小限制的并发阻塞队列
template <typename T>
class BoundedBlockingQueue { 
public: 
   BoundedBlockingQueue (int size) : maxSize(size) { 
       pthread_mutex_init(&_lock, NULL); 
       pthread_cond_init(&_rcond, NULL);
       pthread_cond_init(&_wcond, NULL);
       _array.reserve(maxSize);
    } 
    ~BoundedBlockingQueue ( ) { 
       pthread_mutex_destroy(&_lock);
       pthread_cond_destroy(&_rcond);
       pthread_cond_destroy(&_wcond);
    } 
    void push(const T& data);
    T pop( ); 
private: 
    vector<T> _array; // or T* _array if you so prefer
    int maxSize;
    pthread_mutex_t _lock;
    pthread_cond_t _rcond, _wcond;
}

在解释添加数据操作之前,看一下 清单 14 中的代码。

清单 14. 在有大小限制的阻塞队列中添加数据
void BoundedBlockingQueue <T>::push(const T& value ) { 
       pthread_mutex_lock(&_lock);
       const bool was_empty = _array.empty( );
       while (_array.size( ) == maxSize) { 
           pthread_cond_wait(&_wcond, &_lock);
       } 
       _ array.push_back(value);
      pthread_mutex_unlock(&_lock);
      if (was_empty) 
          pthread_cond_broadcast(&_rcond);
}

锁是否可以扩展到其他数据结构?

当然可以。但这是最好的做法吗?不是。考虑一个应该允许多个线程使用的链表。与队列不同,列表没有单一的插入或删除点,使用单一互斥锁控制对列表的访问会导致系统功能正常但相当慢。另一种实现是对每个节点使用锁,但是这肯定会增加系统的内存占用量。本系列的第二部分会讨论这些问题。

对于 清单 13 和 清单 14,要注意的第一点是,这个阻塞队列有两个条件变量而不是一个。如果队列满了,写线程等待 _wcond 条件变量;读线程在从队列中取出数据之后需要通知所有线程。同样,如果队列是空的,读线程等待 _rcond 变量,写线程在把数据插入队列中之后向所有线程发送广播消息。如果在发送广播通知时没有线程在等待 _wcond 或 _rcond,会发生什么?什么也不会发生;系统会忽略这些消息。还要注意,两个条件变量使用相同的互斥锁。清单 15 给出有大小限制的阻塞队列的 pop() 方法。

清单 15. 从有大小限制的阻塞队列中取出数据
T BoundedBlockingQueue<T>::pop( ) { 
       pthread_mutex_lock(&_lock);
       const bool was_full = (_array.size( ) == maxSize);
       while(_array.empty( )) { 
           pthread_cond_wait(&_rcond, &_lock) ;
       }
       T _temp = _array.front( );
       _array.erase( _array.begin( ));
       pthread_mutex_unlock(&_lock);
       if (was_full)
           pthread_cond_broadcast(&_wcond);
       return _temp;
}

注意,在释放互斥锁之后调用 pthread_cond_broadcast。这是一种好做法,因为这会减少唤醒之后读线程的等待时间。


简介

本文是本系列的最后一篇,讨论两个主题:关于实现基于互斥锁的并发链表的设计方法和设计不使用互斥锁的并发数据结构。对于后一个主题,我选择实现一个并发堆栈并解释设计这种数据结构涉及的一些问题。用 C++ 设计独立于平台的不使用互斥锁的数据结构目前还不可行,所以我选用 GCC version 4.3.4 作为编译器并在代码中使用 GCC 特有的 __sync_* 函数。如果您是 WIndows® C++ 开发人员,可以考虑使用 Interlocked* 系列函数实现相似的功能。

并发单向链表的设计方法

清单 1 给出最基本的并发单向链表接口。是不是缺少什么东西?

清单 1. 并发单向链表接口
template <typename T>
class SList { 
   private: 
       typedef struct Node { 
            T data;
            Node *next;
            Node(T& data) : value(data), next(NULL) { }
       } Node; 
       pthread_mutex_t _lock;
       Node *head, *tail;  
   public: 
       void push_back(T& value);
       void insert_after(T& previous, T& value); // insert data after previous
       void remove(const T& value);
       bool find(const T& value);  // return true on success 
       SList(  );
       ~SList(  ); 
};

清单 2 给出 push_back 方法定义。

清单 2. 把数据添加到并发链表中
void SList<T>::push_back(T& data)
{ 
    pthread_mutex_lock(&_lock);
    if (head == NULL) { 
        head = new Node(data);
        tail = head;
    } else { 
        tail->next = new Node(data);
        tail = tail->next;
    }
    pthread_mutex_unlock(&_lock);
}

现在,假设一个线程试图通过调用 push_back 把 n 个整数连续地添加到这个链表中。这个接口本身要求获取并释放互斥锁 n 次,即使在第一次获取锁之前已经知道要插入的所有数据。更好的做法是定义另一个方法,它接收一系列整数,只获取并释放互斥锁一次。清单 3 给出方法定义。

清单 3. 在链表中插入数据的更好方法
void SList<T>::push_back(T* data, int count) // or use C++ iterators 
{ 
    Node *begin = new Node(data[0]);
    Node *temp = begin;
    for (int i=1; i<count; ++i) {
        temp->next = new Node(data[i]);
        temp = temp->next;
    }
        
    pthread_mutex_lock(&_lock);
    if (head == NULL) { 
        head = begin;
        tail = head;
    } else { 
        tail->next = begin;
        tail = temp;
    }
    pthread_mutex_unlock(&_lock);
}

优化搜索元素

现在,我们来优化链表中的搜索元素 — 即 find 方法。下面是几种可能出现的情况:

  • 当一些线程正在迭代链表时,出现插入或删除请求。
  • 当一些线程正在迭代链表时,出现迭代请求。
  • 当一些线程正在插入或删除数据时,出现迭代请求。

显然,应该能够同时处理多个迭代请求。如果系统中的插入/删除操作很少,主要活动是搜索,那么基于单一锁的方法性能会很差。在这种情况下,应该考虑使用读写锁,即 pthread_rwlock_t。在本文的示例中,将在 SList 中使用 pthread_rwlock_t 而不是 pthread_mutex_t。这么做就允许多个线程同时搜索链表。插入和删除操作仍然会锁住整个链表,这是合适的。清单 4 给出使用 pthread_rwlock_t 的链表实现。

清单 4. 使用读写锁的并发单向链表
template <typename T>
class SList { 
   private: 
       typedef struct Node { 
           // … same as before 
       } Node; 
       pthread_rwlock_t _rwlock; // Not pthread_mutex_t any more!
       Node *head, *tail;  
   public: 
       // … other API remain as-is
       SList(  ) : head(NULL), tail(NULL) { 
           pthread_rwlock_init(&_rwlock, NULL);
       } 
       ~SList(  ) { 
           pthread_rwlock_destroy(&_rwlock);
           // … now cleanup nodes 
       } 
};

清单 5 给出链表搜索代码。

清单 5. 使用读写锁搜索链表
bool SList<T>::find(const T& value)
{ 
    pthread_rwlock_rdlock (&_rwlock);
    Node* temp = head;
    while (temp) { 
         if (temp->value == data) {
              status = true;
              break;
         }
         temp = temp->next;
    }
    pthread_rwlock_unlock(&_rwlock);
    return status;
}

清单 6 给出使用读写锁的 push_back 方法。

清单 6. 使用读写锁在并发单向链表中添加数据
void SList<T>::push_back(T& data)
{ 
    pthread_setschedprio(pthread_self( ), SCHED_FIFO);
    pthread_rwlock_wrlock(&_rwlock);
    // … All the code here is same as Listing 2
    pthread_rwlock_unlock(&_rwlock);
}

我们来分析一下。这里使用两个锁定函数调用(pthread_rwlock_rdlock 和 pthread_rwlock_wrlock)管理同步,使用pthread_setschedprio 调用设置写线程的优先级。如果没有写线程在这个锁上阻塞(换句话说,没有插入/删除请求),那么多个请求链表搜索的读线程可以同时操作,因为在这种情况下一个读线程不会阻塞另一个读线程。如果有写线程等待这个锁,当然不允许新的读线程获得锁,写线程等待,到现有的读线程完成操作时写线程开始操作。如果不按这种方式使用 pthread_setschedprio 设置写线程的优先级,根据读写锁的性质,很容易看出写线程可能会饿死。

下面是对于这种方式要记住的几点:

  • 如果超过了最大读锁数量(由实现定义),pthread_rwlock_rdlock 可能会失败。
  • 如果有 n 个并发的读锁,一定要调用 pthread_rwlock_unlock n 次。

允许并发插入

应该了解的最后一个方法是 insert_after。同样,预期的使用模式要求调整数据结构的设计。如果一个应用程序使用前面提供的链表,它执行的插入和搜索操作数量差不多相同,但是删除操作很少,那么在插入期间锁住整个链表是不合适的。在这种情况下,最好允许在链表中的分离点(disjoint point)上执行并发插入,同样使用基于读写锁的方式。下面是构造链表的方法:

  • 在两个级别上执行锁定(见 清单 7):链表有一个读写锁,各个节点包含一个互斥锁。如果想节省空间,可以考虑共享互斥锁 — 可以维护节点与互斥锁的映射。
  • 在插入期间,写线程在链表上建立读锁,然后继续处理。在插入数据之前,锁住要在其后添加新数据的节点,插入之后释放此节点,然后释放读写锁。
  • 删除操作在链表上建立写锁。不需要获得与节点相关的锁。
  • 与前面一样,可以并发地执行搜索。
清单 7. 使用两级锁定的并发单向链表
template <typename T>
class SList { 
   private: 
       typedef struct Node { 
            pthread_mutex_lock lock;
            T data;
            Node *next;
            Node(T& data) : value(data), next(NULL) { 
                pthread_mutex_init(&lock, NULL);
            }
            ~Node( ) { 
                pthread_mutex_destroy(&lock);
            }
       } Node; 
       pthread_rwlock_t _rwlock; // 2 level locking 
       Node *head, *tail;  
   public: 
       // … all external API remain as-is
       } 
};

清单 8 给出在链表中插入数据的代码。

清单 8. 使用双重锁定在链表中插入数据
void SList<T>:: insert_after(T& previous, T& value)
{ 
    pthread_rwlock_rdlock (&_rwlock);
    Node* temp = head;
    while (temp) { 
         if (temp->value == previous) {
              break;
         }
         temp = temp->next;
    }
    Node* newNode = new Node(value);
    
    pthread_mutex_lock(&temp->lock);
    newNode->next = temp->next;
    temp->next = newNode;
    pthread_mutex_unlock(&temp->lock);

    pthread_rwlock_unlock(&_rwlock);
    return status;
}

基于互斥锁的方法的问题

到目前为止,都是在数据结构中使用一个或多个互斥锁管理同步。但是,这种方法并非没有问题。请考虑以下情况:

  • 等待互斥锁会消耗宝贵的时间 — 有时候是很多时间。这种延迟会损害系统的可伸缩性。
  • 低优先级的线程可以获得互斥锁,因此阻碍需要同一互斥锁的高优先级线程。这个问题称为优先级倒置(priority inversion ) (更多信息见 参考资料 中的链接)。
  • 可能因为分配的时间片结束,持有互斥锁的线程被取消调度。这对于等待同一互斥锁的其他线程有不利影响,因为等待时间现在会更长。这个问题称为锁护送(lock convoying)(更多信息见 参考资料 中的链接)。

互斥锁的问题还不只这些。最近,出现了一些不使用互斥锁的解决方案。话虽如此,尽管使用互斥锁需要谨慎,但是如果希望提高性能,肯定应该研究互斥锁。

比较并交换指令

在研究不使用互斥锁的解决方案之前,先讨论一下从 80486 开始在所有 Intel® 处理器上都支持的 CMPXCHG 汇编指令。清单 9 从概念角度说明这个指令的作用。

清单 9. 比较并交换指令的行为
int compare_and_swap ( int *memory_location, int expected_value, int new_value) 
{ 
  int old_value = *memory_location;
  if (old_value == expected_value) 
     *memory_location = new_value;
  return old_value;
}

这里发生的操作是:指令检查一个内存位置是否包含预期的值;如果是这样,就把新的值复制到这个位置。清单 10 提供汇编语言伪代码。

清单 10. 比较并交换指令的汇编语言伪代码
CMPXCHG OP1, OP2 
if ({AL or AX or EAX} = OP1) 
       zero = 1               ;Set the zero flag in the flag register 
       OP1 = OP2
else
       zero := 0              ;Clear the zero flag in the flag register 
       {AL or AX or EAX}= OP1

CPU 根据操作数的宽度(8、16 或 32)选择 AL、AX 或 EAX 寄存器。如果 AL/AX/EAX 寄存器的内容与操作数 1 的内容匹配,就把操作数 2 的内容复制到操作数 1;否则,用操作数 2 的值更新 AL/AX/EAX 寄存器。Intel Pentium® 64 位处理器有相似的指令 CMPXCHG8B,它支持 64 位的比较并交换。注意,CMPXCHG 指令是原子性的,这意味着在这个指令结束之前没有可见的中间状态。它要么完整地执行,要么根本没有开始。在其他平台上有等效的指令,例如 Motorola MC68030 处理器的 compare and swap (CAS) 指令有相似的语义。

我们为什么对 CMPXCHG 感兴趣?这意味着要使用汇编语言编写代码吗?

需要了解 CMPXCHG 和 CMPXCHG8B 等相关指令是因为它们构成了无锁解决方案的核心。但是,不必使用汇编语言编写代码。GCC (GNU Compiler Collection,4.1 和更高版本)提供几个原子性的内置函数(见 参考资料),可以使用它们为 x86 和 x86-64 平台实现 CAS 操作。实现这一支持不需要包含头文件。在本文中,我们要在无锁数据结构的实现中使用 GCC 内置函数。看一下这些内置函数:

bool __sync_bool_compare_and_swap (type *ptr, type oldval, type newval, ...)
type __sync_val_compare_and_swap (type *ptr, type oldval, type newval, ...)

__sync_bool_compare_and_swap 内置函数比较 oldval 和 *ptr。如果它们匹配,就把 newval 复制到 *ptr。如果 oldval 和 *ptr 匹配,返回值是 True,否则是 False。__sync_val_compare_and_swap 内置函数的行为是相似的,只是它总是返回旧值。清单 11 提供一个使用示例。

清单 11. GCC CAS 内置函数的使用示例
#include <iostream>
using namespace std;
 
int main()
{
   bool lock(false);
   bool old_value = __sync_val_compare_and_swap( &lock, false, true); 
   cout >> lock >> endl; // prints 0x1
   cout >> old_value >> endl; // prints 0x0
}

设计无锁并发堆栈

既然了解了 CAS,现在就来设计一个并发堆栈。这个堆栈没有锁;这种无锁的并发数据结构也称为非阻塞数据结构清单 12 给出代码接口。

清单 12. 基于链表的非阻塞堆栈实现
template <typename T> 
class Stack { 
    typedef struct Node { 
                          T data;
                          Node* next;
                          Node(const T& d) : data(d), next(0) { } 
                        } Node;
    Node *top; 
    public: 
       Stack( ) : top(0) { }
       void push(const T& data);
       T pop( ) throw (…); 
};

清单 13 给出压入操作的定义。

清单 13. 在非阻塞堆栈中压入数据
void Stack<T>::push(const T& data) 
{ 
    Node *n = new Node(data); 
    while (1) { 
        n->next = top;
        if (__sync_bool_compare_and_swap(&top, n->next, n)) { // CAS
            break;
        }
    }
}

压入(Push)操作做了什么?从单一线程的角度来看,创建了一个新节点,它的 next 指针指向堆栈的顶部。接下来,调用 CAS 内置函数,把新的节点复制到 top 位置。

从多个线程的角度来看,完全可能有两个或更多线程同时试图把数据压入堆栈。假设线程 A 试图把 20 压入堆栈,线程 B 试图压入 30,而线程 A 先获得了时间片。但是,在 n->next = top 指令结束之后,调度程序暂停了线程 A。现在,线程 B 获得了时间片(它很幸运),它能够完成 CAS,把 30 压入堆栈后结束。接下来,线程 A 恢复执行,显然对于这个线程 *top 和 n->next 不匹配,因为线程 B 修改了 top 位置的内容。因此,代码回到循环的开头,指向正确的 top 指针(线程 B 修改后的),调用 CAS,把 20 压入堆栈后结束。整个过程没有使用任何锁。

弹出操作

清单 14 给出从堆栈弹出数据的代码。

清单 14. 从非阻塞堆栈弹出数据
T Stack<T>::pop( ) 
{ 
    if (top == NULL) 
       throw std::string(“Cannot pop from empty stack”);
    while (1) { 
        Node* next = top->next;
        if (__sync_bool_compare_and_swap(&top, top, next)) { // CAS
            return top->data;
        }
    }
}

用与 push 相似的代码定义弹出操作语义。堆栈的顶存储在 result 中,使用 CAS 把 top 位置更新为 top->next 并返回适当的数据。如果恰在执行 CAS 之前线程失去执行权,那么在线程恢复执行之后,CAS 会失败,继续循环,直到有有效的数据可用为止。

结果好就一切都好

不幸的是,这种堆栈弹出实现有问题 — 包括明显的问题和不太明显的问题。明显的问题是 NULL 检查必须放在 while 循环中。如果线程 P 和线程 Q 都试图从只剩一个元素的堆栈弹出数据,而线程 P 恰在执行 CAS 之前失去执行权,那么当它重新获得执行权时,堆栈中已经没有可弹出的数据了。因为 top 是 NULL,访问 &top 肯定会导致崩溃 — 这显然是可以避免的 bug。这个问题也突显了并发数据结构的基本设计原则之一:决不要假设任何代码会连续执行。

清单 15 给出解决了此问题的代码。

清单 15. 从非阻塞堆栈弹出数据
T Stack<T>::pop( ) 
{ 
    while (1) { 
        if (top == NULL) 
           throw std::string(“Cannot pop from empty stack”);
        Node* next = top->next;
        if (top && __sync_bool_compare_and_swap(&top, top, next)) { // CAS
            return top->data;
        }
    }
}

下一个问题比较复杂,但是如果您了解内存管理程序的工作方式(更多信息见 参考资料 中的链接),应该不太难理解。清单 16 展示这个问题。

清单 16. 内存的回收利用会导致 CAS 出现严重的问题
T* ptr1 = new T(8, 18);
T* old = ptr1; 
// .. do stuff with ptr1
delete ptr1;
T* ptr2 = new T(0, 1);

// We can't guarantee that the operating system will not recycle memory
// Custom memory managers recycle memory often
if (old1 == ptr2) { 
   …
}

在此代码中,无法保证 old 和 ptr2 有不同的值。根据操作系统和定制的应用程序内存管理系统的具体情况,完全可能回收利用已删除的内存 — 也就是说,删除的内存放在应用程序专用的池中,可在需要时重用,而不返回给系统。这显然会改进性能,因为不需要通过系统调用请求更多内存。尽管在一般情况下这是有利的,但是对于非阻塞堆栈不好。现在我们来看看这是为什么。

假设有两个线程 — A 和 B。A 调用 pop 并恰在执行 CAS 之前失去执行权。然后 B 调用 pop ,然后压入数据,数据的一部分来自从前面的弹出操作回收的内存。清单 17 给出伪代码。

清单 17. 序列图
Thread A tries to pop
Stack Contents: 5 10 14 9 100 2
result = pointer to node containing 5 
Thread A now de-scheduled

Thread B gains control 
Stack Contents: 5 10 14 9 100 2
Thread B pops 5
Thread B pushes 8 16 24 of which 8 was from the same memory that earlier stored 5
Stack Contents: 8 16 24 10 14 9 100 2

Thread A gains control 
At this time, result is still a valid pointer and *result = 8 
But next points to 10, skipping 16 and 24!!!

纠正方法相当简单:不存储下一个节点。清单 18 给出代码。

清单 18. 从非阻塞堆栈弹出数据
T Stack<T>::pop( ) 
{ 
    while (1) { 
        Node* result = top;
        if (result == NULL) 
           throw std::string(“Cannot pop from empty stack”);      
        if (top && __sync_bool_compare_and_swap(&top, result, result->next)) { // CAS
            return top->data;
        }
    }
}

这样,即使线程 B 在线程 A 试图弹出数据的同时修改了堆栈顶,也可以确保不会跳过堆栈中的元素。

结束语

本系列讨论了如何设计支持并发访问的数据结构。您已经看到,设计可以基于互斥锁,也可以是无锁的。无论采用哪种方式,要考虑的问题不仅仅是这些数据结构的基本功能 — 具体来说,必须一直记住线程会争夺执行权,要考虑线程重新执行时如何恢复操作。目前,解决方案(尤其是无锁解决方案)与平台/编译器紧密相关。请研究用于实现线程和锁的 Boost 库,阅读 John Valois 关于无锁链表的文章(见 参考资料 中的链接)。C++0x 标准提供了 std::thread 类,但是目前大多数编译器对它的支持很有限,甚至不支持它。


无锁队列的实现

关于无锁队列的实现,网上有很多文章,虽然本文可能和那些文章有所重复,但是我还是想以我自己的方式把这些文章中的重要的知识点串起来和大家讲一讲这个技术。下面开始正文。


  关于 CAS 等原子操作

  在开始说无锁队列之前,我们需要知道一个很重要的技术就是 CAS 操作——Compare & Set,或是 Compare & Swap,现在几乎所有的 CPU 指令都支持 CAS 的原子操作,X86下对应的是 CMPXCHG 汇编指令。有了这个原子操作,我们就可以用其来实现各种无锁(lock free)的数据结构。

  这个操作用C语言来描述就是下面这个样子:(代码来自 Wikipedia 的 Compare And Swap 词条)意思就是说,看一看内存*reg 里的值是不是 oldval,如果是的话,则对其赋值 newval。

int compare_and_swap (int* reg, int oldval, int newval)
{
int old_reg_val = *reg;
if (old_reg_val == oldval)
*reg = newval;
return old_reg_val;
}

  这个操作可以变种为返回 bool 值的形式(返回 bool 值的好处在于,可以调用者知道有没有更新成功):

bool compare_and_swap (int *accum, int *dest, int newval)
{
if ( *accum == *dest ) {
*dest = newval;
return true;
}
return false;
}

  与 CAS 相似的还有下面的原子操作:(这些东西大家自己看 Wikipedia 吧)

  注:在实际的C/C++程序中,CAS 的各种实现版本如下:

  1)GCC 的 CAS

  GCC4.1+ 版本中支持 CAS 的原子操作(完整的原子操作可参看 GCC Atomic Builtins

bool __sync_bool_compare_and_swap (type *ptr, type oldval type newval, ...)
type __sync_val_compare_and_swap (type *ptr, type oldval type newval, ...)

  2)Windows 的 CAS

  在 Windows 下,你可以使用下面的 Windows API 来完成 CAS:(完整的 Windows 原子操作可参看 MSDN 的 InterLocked Functions

InterlockedCompareExchange ( __inout LONG volatile *Target,
__in LONG Exchange,
__in LONG Comperand);

  3) C++11 中的 CAS

  C++11中的 STL 中的 atomic 类的函数可以让你跨平台。(完整的C++11的原子操作可参看Atomic Operation Library

template< class T >
bool atomic_compare_exchange_weak ( std::atomic<T>* obj,
T* expected, T desired );
template< class T >
bool atomic_compare_exchange_weak ( volatile std::atomic<T>* obj,
T* expected, T desired );

  无锁队列的链表实现

  下面的东西主要来自 John D. Valois 1994 年 10 月在拉斯维加斯的并行和分布系统系统国际大会上的一篇论文——《Implementing Lock-Free Queues》。

  我们先来看一下进队列用 CAS 实现的方式:

EnQueue (x) //进队列
{
//准备新加入的结点数据
q = new record ();
q->value = x;
q->next = NULL;

do {
p = tail; //取链表尾指针的快照
} while( CAS (p->next, NULL, q) != TRUE); //如果没有把结点链上,再试

CAS (tail, p, q); //置尾结点
}

  我们可以看到,程序中的那个 do- while 的 Re-Try-Loo。就是说,很有可能我在准备在队列尾加入结点时,别的线程已经加成功了,于是 tail 指针就变了,于是我的 CAS 返回了 false,于是程序再试,直到试成功为止。这个很像我们的抢电话热的不停重播的情况。

  你会看到,为什么我们的“置尾结点”的操作不判断是否成功,因为:

  1. 如果有一个线程 T1,它的 while 中的 CAS 如果成功的话,那么其它所有的随后线程的 CAS 都会失败,然后就会再循环,
  2. 此时,如果 T1 线程还没有更新 tail 指针,其它的线程继续失败,因为 tail->next 不是 NULL 了。
  3. 直到 T1 线程更新完 tail 指针,于是其它的线程中的某个线程就可以得到新的 tail 指针,继续往下走了。

  这里有一个潜在的问题——如果 T1 线程在用 CAS 更新 tail 指针的之前,线程停掉了,那么其它线程就进入死循环了。下面是改良版的 EnQueue ()

EnQueue (x) //进队列改良版
{
q = new record ();
q->value = x;
q->next = NULL;

p = tail;
oldp = p
do {
while (p->next != NULL)
p = p->next;
} while( CAS (p.next, NULL, q) != TRUE); //如果没有把结点链上,再试

CAS (tail, oldp, q); //置尾结点
}

  我们让每个线程,自己 fetch 指针 p 到链表尾。但是这样的 fetch 会很影响性能。而通实际情况看下来,99.9% 的情况不会有线程停转的情况,所以,更好的做法是,你可以接合上述的这两个版本,如果 retry 的次数超了一个值的话(比如说 3 次),那么,就自己 fetch 指针。

  好了,我们解决了 EnQueue,我们再来看看 DeQueue 的代码:(很简单,我就不解释了)

DeQueue () //出队列
{
do{
p = head;
if (p->next == NULL){
return ERR_EMPTY_QUEUE;
}
while( CAS (head, p, p->next);
return p->next->value;
}

  我们可以看到,DeQueue 的代码操作的是 head->next,而不是 head 本身。这样考虑是因为一个边界条件,我们需要一个 dummy 的头指针来解决链表中如果只有一个元素,head 和 tail 都指向同一个结点的问题,这样 EnQueue 和 DeQueue 要互相排斥了

注:上图的 tail 正处于更新之前的装态。

  DAS 的 ABA 问题

  所谓 ABA(见维基百科的 ABA 词条),问题基本是这个样子:

  1. 进程 P1 在共享变量中读到值为A
  2. P1被抢占了,进程 P2 执行
  3. P2把共享变量里的值从A改成了B,再改回到A,此时被 P1 抢占。
  4. P1回来看到共享变量里的值没有被改变,于是继续执行。

  虽然 P1 以为变量值没有改变,继续执行了,但是这个会引发一些潜在的问题。ABA 问题最容易发生在 lock free 的算法中的,DAS 首当其冲,因为 DAS 判断的是指针的地址。如果这个地址被重用了呢,问题就很大了。

  比如上述的 DeQueue ()函数,因为我们要让 head 和 tail 分开,所以我们引入了一个 dummy 指针给 head,当我们做 CAS 的之前,如果 head 的那块内存被回收并被重用了,而重用的内存又被 EnQueue ()进来了,这会有很大的问题。(内存管理中重用内存基本上是一种很常见的行为

  这个例子你可能没有看懂,维基百科上给了一个活生生的例子——

你拿着一个装满钱的手提箱在飞机场,此时过来了一个火辣性感的美女,然后她很暖昧地挑逗着你,并趁你不注意的时候,把用一个一模一样的手提箱和你那装满钱的箱子调了个包,然后就离开了,你看到你的手提箱还在那,于是就提着手提箱去赶飞机去了。

  这就是 ABA 的问题。

  解决 ABA 的问题

  维基百科上给了一个解——使用 double-CAS(双保险的 CAS),例如,在 32 位系统上,我们要检查 64 位的内容

  1)一次用 CAS 检查双倍长度的值,前半部是指针,后半部分是一个计数器。

  2)只有这两个都一样,才算通过检查,要吧赋新的值。并把计数器累加1。

  这样一来,ABA 发生时,虽然值一样,但是计数器就不一样(但是在 32 位的系统上,这个计数器会溢出回来又从 1 开始的,这还是会有 ABA 的问题)

  当然,我们这个队列的问题就是不想让那个内存重用,这样明确的业务问题比较好解决,论文《Implementing Lock-Free Queues》给出一这么一个方法——使用结点内存引用计数 refcnt

SafeRead (q)
{
loop:
p = q->next;
if (p == NULL){
return p;
}

Fetch&Add (p->refcnt, 1);

if (p == q->next){
return p;
}else{
Release (p);
}
goto loop
}

  其中的 Fetch&Add 和 Release 分是是加引用计数和减引用计数,都是原子操作,这样就可以阻止内存被回收了。

  用数组实现无锁队列

  本实现来自论文《Implementing Lock-Free Queues

  使用数组来实现队列是很常见的方法,因为没有内存的分部和释放,一切都会变得简单,实现的思路如下:

  1)数组队列应该是一个 ring buffer 形式的数组(环形数组)

  2)数组的元素应该有三个可能的值:HEAD,TAIL,EMPTY(当然,还有实际的数据)

  3)数组一开始全部初始化成 EMPTY,有两个相邻的元素要初始化成 HEAD 和 TAIL,这代表空队列。

  4)EnQueue 操作。假设数据x要入队列,定位 TAIL 的位置,使用 double-CAS 方法把(TAIL, EMPTY) 更新成 (x, TAIL)。需要注意,如果找不到(TAIL, EMPTY),则说明队列满了。

  5)DeQueue 操作。定位 HEAD 的位置,把(HEAD, x)更新成(EMPTY, HEAD),并把x返回。同样需要注意,如果x是 TAIL,则说明队列为空。

  算法的一个关键是——如何定位 HEAD 或 TAIL?

  1)我们可以声明两个计数器,一个用来计数 EnQueue 的次数,一个用来计数 DeQueue 的次数。

  2)这两个计算器使用使用 Fetch&ADD 来进行原子累加,在 EnQueue 或 DeQueue 完成的时候累加就好了。

  3)累加后求个模什么的就可以知道 TAIL 和 HEAD 的位置了。

  如下图所示:

   小结

  以上基本上就是所有的无锁队列的技术细节,这些技术都可以用在其它的无锁数据结构上。

  1)无锁队列主要是通过 CAS、FAA 这些原子操作,和 Retry-Loop 实现。

  2)对于 Retry-Loop,我个人感觉其实和锁什么什么两样。只是这种“锁”的粒度变小了,主要是“锁”HEAD 和 TAIL 这两个关键资源。而不是整个数据结构。

  还有一些和 Lock Free 的文章你可以去看看:



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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值