《C++ Concurrency in Action》笔记28 无锁并行数据结构

7 设计无锁并行数据结构

mutex是一种强大的工具,可以保证多个线程安全访问数据结构。使用mutex的目的很直接:访问被保护数据的代码要么锁定了mutex,要么没有。然而,它也有不好的一面,错误的使用它会导致死锁,你也看到了基于锁的队列、查询表例子,锁的粒度可以影响并发性能。如果你能写不使用锁的数据结构,那就可以避免这些问题。这样的数据结构叫做无锁数据结构。

在这一章,你将看到如何使用第5章中所讲的原子操作的内存序列属性是怎样被用于构建无锁数据结构的。设计这样的数据结构你必须要加倍小心,因为它很难正确实现,而且导致设计执行失败的条件出现的几率很小。我将以讨论无锁数据结构意味着什么开始;然后在演示几个例子前看看使用它的原因是什么,然后列出一些通用的指导思想。

7.1 定义和意义

使用mutex、条件变量、future的算法和数据结构被叫做阻塞算法和数据结构。应用程序调用库函数将会暂停一个线程的执行,直到另一个线程做了特定的事情后才能继续。这样的库调用被称为阻塞调用,因为线程无法越过这个点继续执行,直到阻塞被移除。通常,操作系统将会完全暂停一个被阻塞的线程(并将其时间片分配给其他线程),直到它被另一个线程的适当的行为解除阻塞,这个行为可能是解锁一个mutex、通知一个条件变量,或者是使一个future的状态变为ready。

算法或数据结构不使用阻塞库函数,被叫做非阻塞。并不是所有的这样的数据结构都是无锁的,那就让我们来看看不同类型的非阻塞数据结构。

7.1.1 非阻塞数据结构的类型

第5章我们介绍过使用 std::atomic_flag实现的一个自旋锁mutex,下面再次将代码列出来:

Listing 7.1 Implementation of a spin-lock mutex using  std::atomic_flag

class spinlock_mutex
{
	std::atomic_flag flag = ATOMIC_FLAG_INIT;//原书中的代码无法编译通过,必须这样初始化
public:
	spinlock_mutex()
	{}
	void lock()
	{
		while (flag.test_and_set(std::memory_order_acquire));
	}
	void unlock()
	{
		flag.clear(std::memory_order_release);
	}
};

这段代码没有调用阻塞函数;lock()函数仅仅使用循环,直到test_and_set()返回true。这就是为什么它被称为“自旋锁”的原因——代码“旋转”于循环中。无论如何,代码没有阻塞调用,所以任何使用这个mutex来保护数据的代码都是非阻塞的。但它不是无锁的。它仍然是一个mutex,而且在同一时刻只有一个线程可以锁定它。让我们来看一下无锁数据结构的定义,然后你就知道什么类型的数据结构才是无锁的。

7.1.2 无锁数据结构

对于无锁数据结构,必须允许多个线程并行访问。这些线程不是必须可以同时做同一个操作;一个无锁队列可能允许一个线程pop,另一个线程push,但是如果两个线程同时push将破坏数据结构(but break if two threads try to push new items at the same time)。不仅如此,而且如果这些线程之一在操作中途被调度器挂起,其他线程仍然可以不必等待这个线程而完成操作。

使用比较/交换的算法,通常内部包含循环。使用比较/交换的的原因是此刻其他线程可能已经修改了这个数据,如果改了,那么再次调用比较/修改操作之前代码可能需要重做部分操作。如果其他线程被挂起,而这个线程的比较/修改操作终究能够完成,那么这个代码仍然是无锁的;如果不能完成比较/交换操作,那么就需要一个自旋锁了,那这个代码就是非阻塞的,但是不是无锁的。

具有这种循环的无锁算法可能导致一个线程遭受饥饿。如果另一个线程以“错误”的时机执行操作,则当第一个线程在不断重试操作时,另一个线程会继续进行(If another thread performs operations with the “wrong” timing, the other thread might make progress while the first thread continually has to retry its operation. )。避免这种问题的数据结构是无等待的,也是无锁的。

7.1.3 无等待数据结构

无等待数据结构是一种无锁数据结构,并且更进一步:每个访问数据结构的线程都能在有限步骤内完成他的操作,不论其他线程如何操作。而算法则因为可能与其他线程冲突而导致不定次数的重试,因此不是无等待的。

正确编写无等待的数据结构是非常困难的。为了确保每个线程都能在有限步骤内完成操作,你不得不保证每个操作都能一次性通过,而且一个线程的操作不会导致另一个线程的操作失败。这会使各种操作的整体算法更加复杂。

现在知道了想要实现无锁或无等待的数据结构有多难,那么你需要一个足够充分的理由才会去编写它;你需要确定好处大于成本。让我们列举几个影响平衡的要点。

7.1.4 无锁数据结构的优点和缺点

归纳起来,使用无锁数据结构的主要原因就是为了获得最大的并发性。对于基于锁的容器来说,它们总是潜在的互相阻碍,因为mutex的真正目的就是阻止并发。对于无锁数据结构,某些线程可以持续执行每一步。对于无等待数据结构,不论其他线程做什么,每个线程都能持续执行每一步,无需等待。这是一种很理想的属性,但是实现起来太难。很容易最终写成本质上的旋转锁。

第2个使用无锁数据结构的原因是健壮性。如果一个持有锁的线程死掉了,那么这个数据结构就永远被破坏了。但是如果在一个无锁数据结构上发生了这样的事情,其他线程依然可以正常工作,只有死掉的那个线程的数据丢失了。

另一个原因是,当你不能排除有多个线程同时访问数据结构时,你就不得不确保保持住数据的不变性,或者选择其他形式的可以被保持的数据不变性。同时,还需要注意操作的顺序约束。为了避免数据竞争,你必须使用原子操作执行修改操作。那还不够,你还要保证数据变化以正确的顺序被其他线程看到变化的结果。所有这些都意味着编写无锁数据结构比编写有锁数据结构困难。

因为没有任何锁,无锁数据结构不会出现死锁,尽管可能存在活锁(live locks)。活锁发生在两个线程同时试图改变数据结构,但是彼此都需要对方进行重启操作,因此它们都会循环重试。想象两个人都想通过一个狭窄的通道。如果他们同时通过就会卡主,所以他们不得不出去重来。除非有人先走一步(要么是商量好的,要么更快,要么运气好),那么这个循环就会一直持续下去。不过活锁不会持续太久,因为它依赖于线程时序。因此他们会降低性能,尽管不会导致长久的问题,但是仍然需要注意。从定义上讲,一个无等待的代码不会出现或锁,因为用于实现操作的步骤总是有限的。而另一方面,无等待的代码要比需要等待的代码的算饭更加复杂,即使没有其他线程在访问数据结构,也需要更多的执行步骤。

这告诉了我们无锁或无等待的代码的另一个缺点:尽管它能提高数据结构的并发性,并且减少每个线程暂停和等待的时间,但是它可能降低整体性能。首先,无锁代码用到的原子操作要比非原子操作慢得多,其次原子操作可能要比mutex用得更多。不止如此,硬件还需要在多个访问同一个原子变量的线程间同步数据。你将在第8章中看到,与多个访问同一个原子变量的线程相关联的ping-pong缓存会显著降低性能。对于任何事情,都需要在提交之前都应该检查相关性能方面(无论是最坏情况下的等待时间,平均等待时间,总体执行时间还是其他情况)都很重要,无论是基于锁的数据结构还是无锁定的数据结构 。

让我们来看一些例子。

7.2 无锁数据结构的例子

为了演示一些应用与无锁数据结构设计的技术,我们来看一下几种简单数据结构的实现。

由于之前所述的原因,无锁数据结构需要使用原子操作以及内存序列来控制数据在不同线程中的可见顺序。开始,我们使用最简单的memory_order_seq_cst序列,记住,所有使用这个序列的操作具有一个全局确定的顺序。但是在后面的例子中,我们将逐渐放宽对操作顺序的控制:memory_order_acquire ,memory_order_release,甚至是memory_order_relaxed 。

尽管这些例子有没有使用mutex锁,但值得一提的是,只有 std::atomic_flag的实现可以保证完全没有使用锁。一些平台上的C++标准库的无锁代码实际上内部使用了有锁实现(见第5章)。在这些平台上,基于锁的简单数据结构可能会更适合,单页不能一概而论;在选择一种实现前,必须明确你的需求,分别考虑能够满足这些需求的各种选项。

所以,回到最简单的数据结构:stack。

7.2.1 编写一个无锁的线程安全的stack

一个stack的基本需求很简单:节点取出的顺序和节点添加的顺序相反——一句话:后入先出( LIFO)。重要的是确保一旦节点被添加进去,它就能够被其他线程立刻安全的取出,还要保证只有一个线程能得到这个值。最简单的stack实现是链表;头指针指向第一个节点(这个节点就是即将被弹出的节点),然后所有的节点都指向下一个节点。

在这样的机制下,增加一个节点就很简单了:

1.创建一个新节点

2.把他的next设置为当前head指针指向的节点

3.把head指向新节点

这样的设计在单线程环境下工作良好,但是在多线程环境下就不够了。关键是如果有两个线程同时增加节点,那么在第2步和第3步之间就会存在竞争条件,当一个线程在第2步读head以及第3步设置head时,另一个线程可能修改了head的值。这将导致另一个线程的更改被丢弃或更糟的后果。在我们考虑解决这个竞争条件之前,还要考虑到,一旦head指针指向你的新节点,别的线程就能读到这个节点了。在将head指向新节点前,对新节点的所有操作必须完成,此后就不能对新节点做任何事情了,这很重要。

那么这个竞争条件怎么避免呢?答案是在第3步使用一个原子比较/交换操作,以确保你在第2步读取head之后head没有被改变过。如果改变了,就循环重复执行。下面的代码显示如何编写一个无锁的push()函数:

Listing 7.2 Implementing  push() without locks

template<typename T>
class lock_free_stack
{
private:
	struct node
	{
		T data;
		node* next;
		node(T const& data_) ://(1)
			data(data_)
		{}
	};
	std::atomic<node*> head;
public:
	void push(T const& data)
	{
		node* const new_node = new node(data);//(2)
		new_node->next = head.load();//(3)
		while (!head.compare_exchange_weak(new_node->next, new_node));//(4)
	}
};

这段代码几乎匹配了上面所说的3点计划。node的构造函数保证了,一旦创建就已经准备就绪。使用了compare_exchange_weak()函数确保head指针保证和你保存在new_node->next中的值想等,然后将它设置为new_node。这段代码的亮点是使用了比较/交换操作:如果它返回false则代表比较失败(例如,head的值被其他线程修改了),作为第一个参数提供个它的new_node->next会被更新为head的当前值。你不用循环再次获取head的值,因为编译器帮你做了。由于你只是在失败时直接循环,所以你使用compare_exchange_weak(),而不是compare_exchange_strong,在某些平台上可以获得编译器更多的优化(参考第5章)。

现在检查一下push()函数。唯一可能抛出异常的地方就是新节点的构造(1),不过异常会被自行处理,而且list没有被修改,所以很安全。由于你创建了node的数据,并且使用compare_exchange_weak()函数更新head指针,这里没有竞争条件。一旦比较/交换操作执行成功,这个节点就被放到了list上,并且准备好了被取出。这里没有锁,因此不存在死锁,所以push()函数很完美。

现在需要编写pop()函数了,表面看这很简单:

1.读取head当前指向的节点数据。

2.读取head->next

3.令head=head->next

4.返回得到的节点中的数据

5.删除得到的节点

尽管如此,但是遇到多线程就变得不那么简单。如果有两个线程同时执行pop(),他们可能在第1步都取到了相同的head的值。如果其中一个线程在第二个线程执行第2步前将所有的步骤都执行完毕,那么第二个线程将持有一个悬挂指针。这是编写无所代码中的最大的问题之一,所以现在你只能略过第5步,让节点资源泄漏。(so for now you’ll just leave out step 5 and leak the nodes.)

这还不是问题的全部。另外一个问题是:如果两个线程同时读取了head,那么它们将得到同一个节点。这违背了stack数据结构的操作原则,所以你要阻止这种情况的发生。可以使用同样的方法来解决,用比较/交换去更新head。如果比较/交换返回失败,那就代表其他线程添加了一个节点或者取出了一个节点。无论哪种,你都需要返回到步骤1(计较/交换操作会自动重新读取head的值)。

一旦比较/交换操作执行成功,那么你可以确定你是此刻唯一执行pop操作的线程,你可以安全的执行第4步,这是pop的第一个版本:

template<typename T>
class lock_free_stack
{
public:
	void pop(T& result)
	{
		node* old_head = head.load();
		while (!head.compare_exchange_weak(old_head, old_head->next));
		result = old_head->data;
	}
};

尽管这看起来不错而且很简洁,但是仍然有几个关于节点泄漏的问题。

首先,如果list为空则无法工作:如果head为空,则试图获取head->next将导致未定义行为。这很容易解决:通过循环检查head是否为空,如果为空就抛出一个异常,或者返回bool值代表成功或失败。

第二个问题是一个异常安全问题。当我们在第3章第一次介绍异常安全stack时,你看到了返回一个对象时是如何隐含异常问题的:如果在拷贝对象时产生异常,这个数据就丢失了。第3章中的使用引用参数返回值的做法是这样的:

void pop(T& value)
{
	lock_guard<mutex> lock(m);
	if (data.empty()) 
		throw empty_stack();
	value = data.top();
	data.pop();
}

它是先锁定了mutex,然后拷贝值,然后才pop,整个操作都处于mutex保护下,而且即使拷贝产生异常也没有修改stack的数据,因此是异常安全的。

但是这里则不同,在执行拷贝的时候并没有锁定这个节点,而且已经修改了stack的数据,如果拷贝时产生异常则拷贝失败,而stack也已经无法还原了。在此处,你只有一次拷贝的机会,那就是当你知道你是唯一读取这个节点的线程时,当且仅当你把这个节点移出时。反过来,如果没有移出这个节点,你就无法得出你是唯一读取这个节点的线程,你也就无法执行拷贝操作。如果你想安全的返回一个值,你不得不效仿第3章,返回一个指针。

如果返回一个指针,当stack没有数据时你可以仅仅返回一个空指针。但是使用指针意味着需要在堆上分配内存。如果在pop中从堆上分配内存,那么这个函数反而变得不好了,因为在堆上分配内存可能会抛出异常。要是那样的话,还不如在push时分配了——反正都要分配。

如果返回一个 std::shared_ptr<>,那么pop就是安全的。下面给出把这些事情都考虑到的实现代码:

Listing 7.3 A lock-free stack that leaks nodes

template<typename T>
class lock_free_stack
{
private:
	struct node
	{
		std::shared_ptr<T> data;//(1)现在使用智能指针保存数据
		node* next;
		node(T const& data_) : data(std::make_shared<T>(data_))//(2)使用智能指针创建新数据
		{}
	};
	std::atomic<node*> head;
public:
	void push(T const& data)
	{
		node* const new_node = new node(data);
		new_node->next = head.load();
		while (!head.compare_exchange_weak(new_node->next, new_node));
	}
	std::shared_ptr<T> pop()
	{
		node* old_head = head.load();
		while (old_head && //(3)在解引用之前先检查old_head以确保其不为空
			!head.compare_exchange_weak(old_head, old_head->next));
		return old_head ? old_head->data : std::shared_ptr<T>();//(4)
	}
};

注意,这个stack是无锁的,但是不是无等待的,因为pop和push中的while循环在理论上可能因为 compare_exchange_weak()一直失败而永远循环下去。

(补充:因为没有第5步,所以stack中new的节点在pop之后没有执行delete操作,自然存在内存泄漏。原因在本节之前已经讲过)

(这里留下一个问题,待有时间研究:是否可以使用std::atomic<std::shared_ptr<node>>来保存head,这样就不用管它销毁的事了)

7.2.2 阻止内存泄漏:在无锁数据结构中管理内存

上节中的程序存在内存泄漏,这在良好的C++程序设计中是不能接受的,下面我们研究如何解决这个问题。

基本的问题就是你想删除一个节点,但是只有在你确定别的线程没有持有这个节点的指针时你才能删除它。如果只有一个线程对一个stack实例调用了pop()函数,那么事情就好办了。一旦节点被添加到stack,push()函数就不会再碰触它,因此调用pop()函数的线程一定是唯一接触这个节点的线程,那么它就可以安全的删除它。

另一面,如果你想处理多个线程同时对一个stack调用pop()的情况,你需要某种方式以追踪什么时候才能安全的删除一个节点。这从本质上意味着你需要编写一个专门用于节点的垃圾回收器。现在这听起来有点恐怖,但是尽管这很棘手,但是情况也不是很坏:你只需要检查node,而且只需要检查被pop访问过的node。你不必担心在push中的节点。

如果没有线程调用pop,那么就可以放心的删除所有等待被删除的节点。怎么才能知道没有任何线程调用pop呢?简单——线程计数。如果你在进入pop时增加计数,而在离开pop时减少计数,那么当计数为0时,就可以安全的删除所有即将被删除的节点。当然,必须使用一个原子计数器以便于多个线程同时访问。下面列出修改后的pop函数:

Listing 7.4 Reclaiming nodes when no threads are in  pop()

std::atomic<unsigned> threads_in_pop;//(1)原子变量
void try_reclaim(node* old_head);
public:
std::shared_ptr<T> pop()
{
	++threads_in_pop;//(2)在操作前递增计数器
	node* old_head = head.load();
	while (old_head && !head.compare_exchange_weak(old_head, old_head->next));
	std::shared_ptr<T> res;
	if (old_head)
	{
		res.swap(old_head->data);//(3)只是回收节点中的数据
	}
	try_reclaim(old_head);//(4)从node中提取数据而不是拷贝指针
	return res;
}

原子变量threads_in_pop用于记录此刻正在执行pop的线程数。在pop()开始时自增,在结束时使用try_reclaim()自减,一旦节点被删除就调用try_reclaim()。因为你延迟删除节点,所以可以使用swap()函数删除node中的数据(3)(将其数据的智能指针swap到一个shared_prt中,函数退出后就会删除这个数据),而不是直接拷贝只能指针。下面的代码显示了函数try_reclaim()的实现:

Listing 7.5 The reference-counted reclamation machinery

std::atomic<node*> to_be_deleted;
static void delete_nodes(node* nodes)
{
	while (nodes)
	{
		node* next = nodes->next;
		delete nodes;
		nodes = next;
	}
}
void try_reclaim(node* old_head)
{
	if (threads_in_pop == 1)//(1)此刻只有本线程在执行pop
	{
		node* nodes_to_delete = to_be_deleted.exchange(nullptr);//(2)原子操作取出to_be_deleted,在此之前可能有别的线程向悬挂列表中添加了一个节点
		if (!--threads_in_pop)//(3)为0代表自己是唯一执行pop的线程
		{
			delete_nodes(nodes_to_delete);//(4)
		}
		else if (nodes_to_delete)//(5)还有其他线程在执行pop,并且to_be_deleted不为空,代表悬挂列表里面可能已经被别的线程添加了暂时不能删除的节点
		{
			chain_pending_nodes(nodes_to_delete);//(6)那就将这些取出的悬挂节点重新添加到悬挂列表中
		}
		delete old_head;//(7)当前处理的节点无论如何是可以删除的
	}
	else
	{
		chain_pending_node(old_head);//(8)将当前处理的节点添加到悬挂列表中
		--threads_in_pop;
	}
}
void chain_pending_nodes(node* nodes)
{
	node* last = nodes;
	while (node* const next = last->next)//(9)跟随下一个直到结尾
	{
		last = next;
	}
	chain_pending_nodes(nodes, last);
}
void chain_pending_nodes(node* first, node* last)
{
	last->next = to_be_deleted;//(10)
	while (!to_be_deleted.compare_exchange_weak(last->next, first));//(11)循环以保证last->next是正确的
}
void chain_pending_node(node* n)
{
	chain_pending_nodes(n, n);//(12)
}


当你尝试回收节点时,如果threads_in_pop为1,那么你是唯一处于pop()中的线程,那意味着你可以安全的直接删除这个节点(7),并且删除挂起的节点也是安全的。如果计数不为1,那么删除任何节点都是不安全的,所以你必须把节点添加到悬挂列表中(8)。

假设,某一时刻threads_in_pop为1。你需要试图回收悬挂列表中的节点;否则它们就只能一直保持到stack销毁时。为了做这件事,首先你使用一个原子交换操作来取出悬挂列表中的node(2),然后令 threads_in_pop自减(3)。如果自减后 threads_in_pop为0,说明没有其他线程访问悬挂列表中的节点。可能会有新增的悬挂节点,但是此刻不必担心它们,删除悬挂链表是安全的,即使有新增的悬挂节点也是重新往to_be_deleted开头的链表里添加,但这与刚才取出的列表已经毫无关系。接着你就可以使用 delete_nodes()函数迭代删除悬挂节点(4)。

如果自减后threads_in_pop不是0,那么删除悬挂节点就是不安全的,如果悬挂列表中还有节点(5),这意味着,在获取悬挂链表(1)之后,执行交换操作(2)之前这段时间内可能有其他的线程往悬挂列表中添加了暂时不能删除的节点(因为pop中用到了head->next,这个head暂时不能其他线程删除),那就需要将这些取出的悬挂节点重新添加到悬挂列表中(6)。

chain_pending_nodes()函数负责将指定的list重新添加回悬挂list中(9)。

这种设计的代码在低负荷情况下工作良好,因为存在大量的静态时间点:此刻没有线程在执行pop()函数。然而这种时刻稍纵即逝,所以需要测试threads_in_pop自减后是否为0(3),这也是在删除悬挂链表以及删除自身节点之前测试的原因(7)。删除节点可能是个耗时的操作,而你希望留给其他线程的时间空隙尽可能的小。在 threads_in_pop等于1与开始删除节点之间的时间空隙越长,那么别的线程执行pop()的可能性就越大, threads_in_pop就可能不再等于1了,这将会阻止节点的实际删除,使悬挂链表越来越长。

在高负荷情况下,可能永远不存在这样的静态时间点,一个线程还没有离开pop(),其他线程就再次进入pop()了。在这种情况下,to_be_deleted链表将会无限增长下去,本质上还是出现了节点泄漏。如果没有了静态时间点,那么你就不得不继续寻找其他方案来回收节点了。关键是找出什么时候某个节点再也不会被访问到,那就可以放心的删除它了。目前为止,这种机制的最简单实现就是风险指针。(By far the easiest such mechanism to reason about is the use of hazard pointers.)

7.2.3 使用风险指针检测不可回收节点

术语“风险指针”源自于Maged Michael发现的一项技术。之所以这么叫的原因是,删除一个可能仍然被另一个线程使用的节点是有风险的。如果其他线程的确持有一个被删除节点的指针,那么当他访问它时就会引发未定义行为。基本的思路是这样的,如果一个线程即将访问一个其他线程可能想要删除的对象时,它首先设置一个风险指针来指向这个对象,这可以通知其他线程:删除这个对象是有风险的。一旦对象的确不再被使用了,风险指针就被清除。如果你曾经看过牛津/剑桥的划船比赛,你会看到在比赛开始时使用的一种类似的机制:每条船上的船员都可以举起手以示还没有准备好。只要有任何一个船员举手,裁判都不会开始比赛。如果所有船员都将手放下,比赛就可以开始了,但是只要比赛还没开始,那么任何船员如果有事仍然可以再次举手。

当一个线程想要删除一个对象时,它必须首先检查属于其他线程的风险指针。如果没有风险指针指向这个对象,那就可以安全删除。否则就必须晚一些再删除。周期检测列表中哪些对象现在可以被删除。

如何在C++中实现呢?

首先,需要一个位置来保存正在访问对象的风险指针。这个位置对于所有线程必须是可见的,线程可以通过风险指针来访问数据结构。让这些变得正确而且有效是一种挑战,所以先不考虑那么多,假设有一个函数 get_hazard_pointer_for_current_thread(),用于返回一个风险指针的引用。当你读取一个指针并且想要解引用时,你需要设置它,这种情况下的head的值是这样得到的:

std::shared_ptr<T> pop()
{
	std::atomic<void*>& hp = get_hazard_pointer_for_current_thread();
	node* old_head = head.load();//(1)
	node* temp;
	do
	{
		temp = old_head;
		hp.store(old_head);//(2)
		old_head = head.load();
	} while (old_head != temp);//(3)
	// ...
}

你必须在循环中做这些事以确保在读取老的head指针(1)以及将其保存到风险指针(2)之间,这个节点没有被删除。在这期间,其他线程不知道你在访问这个节点。幸运的是,如果老的head指向的节点将被删除,那么head本身肯定已经被改变了(换成了下一个节点或者更后面的节点),所以你可以循环检测直到你得到的head指针和设置到风险指针中的值想等(3)。像这样使用风险指针是依赖于这样的一个事实:如果一个指针所指向的对象被删除,那么仅仅使用指针本身(不进行解引用操作)是安全的。但是针对这样的指针,如果使用缺省的new和delete操作则是未定义行为,所以你要么保证你的实现避免这种情况,要么干脆自己实现分配器以允许它的使用。

现在已经设置了风险指针,可以继续pop()函数的其他部分了。现在可以安全的知道,没有其他线程将删除当下的节点。每一次重新读取old_head后,都需要在解引用前将其更新到风险指针中。一旦从容器中提取出一个节点,就可以清除风险指针了。如果没有其他线程的风险指针指向提取出的节点,就可以安全的删除节点了;否则你就需要将它放置到一个悬挂列表中,稍后删除。

下面列出一个使用这种机制的pop()函数的完整实现:

std::shared_ptr<T> pop()
{
	std::atomic<void*>& hp = get_hazard_pointer_for_current_thread();
	node* old_head = head.load();
	do
	{
		node* temp;
		do//(1)循环直到将风险指针设置为head
		{
			temp = old_head;
			hp.store(old_head);
			old_head = head.load();
		} while (old_head != temp);
	} while (old_head &&
		!head.compare_exchange_strong(old_head, old_head->next));
	hp.store(nullptr);//(2)一旦完成,清除风险指针
	std::shared_ptr<T> res;
	if (old_head)
	{
		res.swap(old_head->data);
		if (outstanding_hazard_pointers_for(old_head))//(3)在删除前检查是否还有其他线程的风险指针正指向这个节点
		{
			reclaim_later(old_head);//(4)
		}
		else
		{
			delete old_head;//(5)
		}
		delete_nodes_with_no_hazards();//(6)
	}
	return res;
}

首先,要使用两个while循环分别保证head被存放到风险指针中,以及当前得到的就是head,并且将head从list中提取出来。之后就可以清除风险指针了(2)。然后检查这个节点是否被其他线程的风险指针引用(3),如果有则将这个节点放到待删除列表中(4),如果没有则删除这个节点(5)。然后执行delete_nodes_with_no_hazards()函数清理一下待删除列表中没有被风险指针指向的节点(6)。剩下的暂时不能删除的节点将会在下一次pop()函数中继续尝试删除。

当然,还有许多细节隐含在这些新函数中,下面来看一下。

get_hazard_pointer_for_current_thread()函数用于给线程分配一个风险指针实例,它跟程序逻辑没有什么关系(本质上会影响效率,稍后介绍)。所以现在先使用一个简单的结构:一个固定大小的数组,数据项是一个保存线程ID和指针的结构体。 get_hazard_pointer_for_current_thread()函数会搜索数组中的可用位置,并将线程ID写入,然后将指针返回。如果这个位置被释放就使用std::thread::id()缺省构造函数来填充ID。下面看一下:

Listing 7.7 A simple implementation of  get_hazard_pointer_for_current_thread()

unsigned const max_hazard_pointers = 100;
struct hazard_pointer
{
	std::atomic<std::thread::id> id;
	std::atomic<void*> pointer;
};
hazard_pointer hazard_pointers[max_hazard_pointers];
class hp_owner
{
	hazard_pointer* hp;
public:
	hp_owner(hp_owner const&) = delete;
	hp_owner operator=(hp_owner const&) = delete;
	hp_owner() : hp(nullptr)
	{
		for (unsigned i = 0; i<max_hazard_pointers; ++i)
		{
			std::thread::id old_id;
			if (hazard_pointers[i].id.compare_exchange_strong(old_id, std::this_thread::get_id()))
			{
				hp = &hazard_pointers[i];
				break;
			}
		}
		if (!hp)//(1)
		{
			throw std::runtime_error("No hazard pointers available");
		}
	}
	std::atomic<void*>& get_pointer()
	{
		return hp->pointer;
	}
	~hp_owner()//(2)
	{
		hp->pointer.store(nullptr);
		hp->id.store(std::thread::id());
	}
};
std::atomic<void*>& get_hazard_pointer_for_current_thread()//(3)
{
	thread_local static hp_owner hazard;//(4)每个线程都有自己的风险指针
	return hazard.get_pointer();//(5)
}

hp_owner的构造函数中使用循环查找是否有没有被占用的数组元素,这里使用了compare_exchange_strong()做一次性比较,而不是循环使用compare_exchange_weak(),目的是只要发现当前的ID不等于std::thread::id()就切换到下一个元素再比较,而不是希望一直比较直到这个元素的ID等于std::thread::id()。如果循环到数组末尾依然没有找到未被占用的元素,那么就说明当前同时使用pop的线程数量已经达到了100个,此时主动抛出一个异常。

一旦hp_owner实例被一个给定的线程创建,那么指针就以引用的形式返回,并被线程记住,以后再访问就很快了,无需再循环查找。

如果一个线程创建了这个hp_owner实例,那么在这个线程销毁时,实例就被销毁了(因为这个实例是线程本地静态变量)。析构函数会将这个线程占有的元素位置释放。注意,由于可能被多个线程同时访问,析构时要先清空指针,然后再清空ID,如果反着来就会出问题。

函数outstanding_hazard_pointers_for()就很简单了,只是扫描数组即可:

bool outstanding_hazard_pointers_for(void* p)
{
	for (unsigned i = 0; i<max_hazard_pointers; ++i)
	{
		if (hazard_pointers[i].pointer.load() == p)
		{
			return true;
		}
	}
	return false;
}

这里没必要去检查每个元素的线程ID,毫无意义,因为线程推出前会先删除指针然后才去删除ID,只要指针在ID跟定在。因此这是最简单的实现。

reclaim_later()和delete_nodes_with_no_hazards()在一个简单的list上工作。reclaim_later()只是往list中添加节点, delete_nodes_with_no_hazards()扫描list,删除没有风险指针指向的节点。下面列出实现:

Listing 7.8 A simple implementation of the reclaim functions

template<typename T>
void do_delete(void* p)
{
	delete static_cast<T*>(p);
}
struct data_to_reclaim
{
	void* data;
	std::function<void(void*)> deleter;
	data_to_reclaim* next;
	template<typename T>
	data_to_reclaim(T* p) :data(p),deleter(&do_delete<T>),next(0)//(1)
	{}
	~data_to_reclaim()
	{
		deleter(data);//(2)
	}
};
std::atomic<data_to_reclaim*> nodes_to_reclaim;
void add_to_reclaim_list(data_to_reclaim* node)//(3)
{
	node->next = nodes_to_reclaim.load();
	while (!nodes_to_reclaim.compare_exchange_weak(node->next, node));
}
template<typename T>
void reclaim_later(T* data)//(4)
{
	add_to_reclaim_list(new data_to_reclaim(data));//(5)
}
void delete_nodes_with_no_hazards()
{
	data_to_reclaim* current = nodes_to_reclaim.exchange(nullptr);//(6)
	while (current)
	{
		data_to_reclaim* const next = current->next;
		if (!outstanding_hazard_pointers_for(current->data))//(7)
		{
			delete current;//(8)
		}
		else
		{
			add_to_reclaim_list(current);//(9)
		}
		current = next;
	}
}

首先希望你能注意到reclaim_later()函数是个函数模板,这是因为风险指针是一种通用结构,而不是专门为stack设计的。需要使用std::atomic<void*>来保存指针。但是当你想要删除指针对应的对象时却不能对void*直接使用delete,因为delete需要对象的指针类型信息。data_to_reclaim的构造函数处理了这件事,它使用了一个do_delete()函数模板的实例指针作为删除函数,并且封装在std::function<>中,这样在销毁对象时就知道对象的类型了。

遍历风险指针数组需要检查 max_hazard_pointers个原子变量,而且这些操作在每一个pop()函数中都需要做。原子操作本质上是慢的 - 执行通常比桌面CPU上的等效非原子操作慢的100次原子操作,因此这使得pop()成为一项昂贵的操作。你在删除一个节点时需要扫描风险指针数组,在清除等待删除的节点时还需要扫描等待列表。这着实不是个好主意。如果某个时刻有100个节点在列表中,那么每次你都要全部扫描一遍!有没有更好的办法呢?

更好的风险指针回收策略

当然有更好的办法,这里通过一个简单而淳朴的风险指针的实现来说明这项技术。第一件你能做的就是用内存消耗来换取性能提升。不在pop()时去扫描整个待删除列表,如果列表中的节点数不大于 max_hazard_pointers时,你就不去回收节点。那样,你就至少能够保证回收一个节点。如果你只是等到有 max_hazard_pointers+1各节点的话,那么不太好。一旦有max_hazard_pointers各节点时,你就会着急在大部分pop调用中回收节点,这也不好。但是如果等到有2* max_hazard_pointers个节点时,你要保证能够删除至少max_hazard_pointers个节点,在试图删除节点前,至少能保证有max_hazard_pointers次pop()次调用(无需删除节点)。你仅需要每max_hazard_pointers次pop调用后在push()中检查当前是否存在2*max_hazard_pointers个节点,然后删除max_hazard_pointers个悬挂节点。

尽管这种方法存在弊端(需要更多的内存消耗):你必须对待删除节点计数,那就需要一个原子变量,而且仍然存在多个线程同时访问待删除列表的情况。如果内存足够的话,应该以内存消耗话来更好的机制:每个线程都有一份使用线程本地变量保存的待删除列表。那就不需要对计数使用原子变量,或者对待删除链表使用原子变量。取而代之的,你需要分配max_hazard_pointers*max_hazard_pointers个节点。如果线程在他的所有待删除节点被删除前退出,那么就可以先把这个链表存放在全局list中,然后传给下一个线程去删除。

风险指针的另一个缺点就是,它已经被IBM申请为专利。

还有一种方案,就是使用引用计数。

7.2.4 使用引用计数删除节点

回看7.2.2,问题在于删除节点时可能还有别的线程在访问这个节点。如果你能安全精确的标识出那个节点被引用,当它不被引用的时候你就可以删除它了。风险指针的问题在于保存了一个链表。计数器的问题在于为每个节点保存了一个当前访问的线程数。

这看起来简单,但是在实践中却很难。首先,你可能想到使用shared_ptr<>,毕竟,它是指针的引用计数。不幸的是,尽管在std::shared_ptr<>上的一些操作是原子的,但是它们不能保证是无锁的。虽然它自身与原子操作并无区别,但它毕竟是为了许多应用场合所设计的,如果令它的原子操作是无锁的,那势必会对所有的使用者强加上性能开销。如果在你的平台上能找到 std::atomic_is_lock_free(&some_shared_ptr)==true的类型,那么整个内存回收问题就解决了。直接在list中使用shared_ptr<node>,就像下面这样:

Listing 7.9 A lock-free stack using a lock-free  std::shared_ptr<> implementation

template<typename T>
class lock_free_stack
{
private:
	struct node
	{
		std::shared_ptr<T> data;
		std::shared_ptr<node> next;
		node(T const& data_) :
			data(std::make_shared<T>(data_))
		{}
	};
	std::shared_ptr<node> head;
public:
	void push(T const& data)
	{
		std::shared_ptr<node> const new_node = std::make_shared<node>(data);
		new_node->next = head.load();
		while (!std::atomic_compare_exchange_weak(&head, &new_node->next, new_node));
	}
	std::shared_ptr<T> pop()
	{
		std::shared_ptr<node> old_head = std::atomic_load(&head);
		while (old_head && !std::atomic_compare_exchange_weak(&head, &old_head, old_head->next));
		return old_head ? old_head->data : std::shared_ptr<T>();
	}
};

在某些情况下,std::shared_ptr<>的实现并非无锁,你需要手动管理引用计数。

一种可能的机制是为每一个node增加2个计数器:一个内部计数以一个外部计数。两个计数的和就是这个节点被引用的总数。外部计数伴随指向这个节点的指针,当指针被读取时自增。当读取完成时,递减内部计数。当不再需要外部计数器/指针时(节点所在内存地址不会再被其他线程访问),内部计数器增加了外部计数器-1个数量,然后外部计数器被丢弃。一旦内部计数器变成0,就可以安全删除节点了。使用原子操作更新数据依然很重要。让我们来看看,使用这种机制的stack,只在节点可以安全删除时才去删除。

下面列出了数据结构和push函数的实现:

Listing 7.10 Pushing a node on a lock-free stack using split reference counts

template<typename T>
class lock_free_stack
{
private:
	struct node;
	struct counted_node_ptr//(1)
	{
		int external_count;
		node* ptr;
	};
	struct node
	{
		std::shared_ptr<T> data;
		std::atomic<int> internal_count;//(2)
		counted_node_ptr next;//(3)
		node(T const& data_) :
			data(std::make_shared<T>(data_)),
			internal_count(0)
		{}
	};
	std::atomic<counted_node_ptr> head;//(4)
public:
	~lock_free_stack()
	{
		while (pop());
	}
	void push(T const& data)//(5)
	{
		counted_node_ptr new_node;
		new_node.ptr = new node(data);
		new_node.external_count = 1;
		new_node.ptr->next = head.load();
		while (!head.compare_exchange_weak(new_node.ptr->next, new_node));
	}
};

由于counted_node_ptr是个简单的数据结构,所以可以使用它的原子类型作为head的类型。

这个实现支持“双字比较和交换”(double-word-compare-and-swap)操作,这个结构足够小以便于使用std::atomic<counted_node_ptr>实现无锁。如果你的平台不支持这种实现,那你最好使用 std::shared_ptr<>就像7.9那个版本,因为当类型对于原子指令来说太大时(那会使你的无锁算法变成了有锁算法)std::atomic<>将会使用mutex来保证原子性质。如果那样的话,如果你想限制计数器的大小,而且你知道你的平台的指针有空余位(例如,地址空间只有48位而指针需要64位),你可以把计数器存放在指针的空余位中,以形成一个单独的机器字。这种做法需要特定平台的相关知识,已经超出了本书的范围。

push()是相对简单的,执行之后,内部计数器为0,外部计数器为1。由于这是一个新节点,它当前只有一个外部引用(head指针自己)。

pop()则复杂一些,看下面:

Listing 7.11 Popping a node from a lock-free stack using split reference counts

template<typename T>
class lock_free_stack
{
private:
	void increase_head_count(counted_node_ptr& old_counter)
	{
		counted_node_ptr new_counter;
		do
		{
			new_counter = old_counter;
			++new_counter.external_count;
		} while (!head.compare_exchange_strong(old_counter, new_counter));//(1)
		old_counter.external_count = new_counter.external_count;
	}
public:
	std::shared_ptr<T> pop()#
	{
		counted_node_ptr old_head = head.load();
		for (;;)
		{
			increase_head_count(old_head);
			node* const ptr = old_head.ptr;//(2)
			if (!ptr)
			{
				return std::shared_ptr<T>();
			}
			if (head.compare_exchange_strong(old_head, ptr->next))//(3)
			{
				std::shared_ptr<T> res;
				res.swap(ptr->data);//(4)
				int const count_increase = old_head.external_count - 2;//(5)
				if (ptr->internal_count.fetch_add(count_increase) ==//(6)
					-count_increase)
				{
					delete ptr;
				}
				return res;//(7)
			}
			else if (ptr->internal_count.fetch_sub(1) == 1)
			{
				delete ptr;//(8)
			}
		}
	}
};

这次,一旦从head读取值,必须将head的外部引用计数加1,确认你此刻正引用它,而且确定对它解引用是安全的。如果你在增加计数之前解引用,其他线程可能在你访问这个节点前释放它,这就导致你持有一个悬挂指针。 这是使用分离计数器的主要原因:通过增加外部计数,你能确定你在访问它时它仍然有效。递增操作循环使用compare_exchange_strong()函数(1)来比较和设置整个结构体,以确定head指针没有被其他线程改变。

一旦计数被增加,你就可以从old_head中取出ptr成员,以处理其指向的节点(2)。如果pte为空代表已经到了列表末尾。如果不为空,那么使用一次compare_exchange_strong()函数试着从list中取出当前head节点(3)。

如果compare_exchange_strong()成功,你就已经取得了这个节点的所有权,然后可以从中交换出数据返回(4)。然后可以使用一个fetch_add将外部计数器的数值加到内部计数器中(6)。如果如果没有人在引用这个节点了,那么从fetch_add返回的值等于负的(刚才加)的外部计数器的值,此时可以删除节点。重要的是,你加的数值是外部计数器的数值减去2(5);这个数字2的意思是:节点已经不在list中,并且你再也不会访问它了。无论是否可以删除节点你都可以返回数据了(7)。

如果比较/交换操作失败了,那么相当于另一个线程在你之前将节点取出,或者另一个线程push()了一个节点。无论怎样都需要重新开始。但是,首先你需要递减这个节点的引用计数。这个线程再也不会去访问者节点了。如果你是最后一个持有它的引用的线程(因为其他线程已经将这个节点从stack中取出了),内部计数应该是1,所以你令内部计数自减1,将会使内部计数变为0。所以你可以删除它了(8)。

到目前为止,所有原子操作使用的都是 std::memory_order_seq_cst序列。在大部分系统中,它的执行时间和同步的开销都要大于其他序列,在一些系统中更为明显。现在你已经有了正确的数据结构逻辑,你可以思考如何放宽对内存序列的要求;你不希望对使用stack的用户强加任何不必要的开销。

所以,现在先将stack放在一边,将注意力转向无锁队列上,让我们回想stack的操作,并且自问是否可以在保持同要高水平的安全级别上,增加更过自由的内存序列呢?

7.2.5 将内存模型应用与无锁stack

在打算改变内存序列之前,应该检查所有操作,标定它们之间有什么样的关系。之后你可以回头找到符合这些关系的最小内存序列。为了实现这个目的,你不得不从不同情况下的多个线程的视角点入手。最简单的情形就是一个线程push了一个节点,稍后另一个线程pop了一个节点。

在这种简单情况下,有3个涉及到数据的重要之处。第一个就是使用counted_node_ptr传递数据的head;第二个就是head指向的节点的数据结构;第三个就是节点指向的数据项。

首先执行push的线程构造数据项和node然后设置head。首先执行pop的线程读取head,然后在head上使用一个比较/交换操作的循环增加引用计数,然后读取节点数据以获取next指针。到这里你会看到一个required关系;next指针是一个普通的非原子对象,所以为了安全读取它,必须在存储操作(由执行push的线程执行)和读取操作(由执行pop的线程执行)之间有一个happens-before关系。由于在push中唯一的原子操作是compare_exchange_weak(),而你有需要一个release操作以获取线程之间的happens-before关系, compare_exchange_weak()必须使用一个memory_order_release或者比它更强级别的序列。如果 compare_exchange_weak()操作执行失败,没有什么被改变,你继续执行循环,所以在失败的情况下只需要使用std::memory_order_relaxed:

void push(T const& data)
{
	counted_node_ptr new_node;
	new_node.ptr = new node(data);
	new_node.external_count = 1;
	new_node.ptr->next = head.load(std::memory_order_relaxed)
		while (!head.compare_exchange_weak(new_node.ptr->next, new_node,
			std::memory_order_release, std::memory_order_relaxed));
}

那么pop()函数呢?为了得到happens-before关系,在访问next之前必须有一个使用std::memory_order_acquire或者更强级别序列的操作。用于解引用以获取next指针的是你从increase_head_count() 函数中获取的老的head指针,increase_head_count()函数中使用了compare_exchange_strong()函数,所以,如果它成功了那么需要对其指定内存序列,同样,失败的情况下因为还是会重新循环,因此可以使用std::memory_order_relaxed:

void increase_head_count(counted_node_ptr& old_counter)
{
	counted_node_ptr new_counter;
	do
	{
		new_counter = old_counter;
		++new_counter.external_count;
	} while (!head.compare_exchange_strong(old_counter, new_counter,
		std::memory_order_acquire, std::memory_order_relaxed));
	old_counter.external_count = new_counter.external_count;
}

如果 compare_exchange_strong()执行成功了,那么你就会知道对数据的读取会使 old_counter中的ptr更新为现在的值。因为,push中的存储是release操作,而这里的 compare_exchange_strong()是一个acquire操作,存储同步于读取,你获得了一个happens-before关系。因此,push函数中对ptr的存储发生在pop函数中对ptr->next的读取之前,操作是安全的。

注意,初始执行的 head.load()并不影响这个分析,因此可以放心的使用 std::memory_order_relaxed。

接下来 compare_exchange_strong()将head设置为head->next。那么你需要为此操作做什么事情以保证线程数据完整性吗?

如果交换成功,你访问ptr->data,所以你需要确保push函数中对ptr->data的存储操作发生在读取操作之前。即便是这样也无妨,因为你已经有了保障:increase_head_count()函数中的aquire操作确保了在push中的存储操作和increase_head_count()中的比较交换操作是同步于的关系。因为在push函数中对data的存储操作是sequenced before于对head的存储的,而且对increase_head_count()的调用sequenced before于对ptr->data的读取操作的,这里有一个happens-before关系,即使pop中的比较/交换操作使用的是 std::memory_order_relaxed 序列那一切也是OK的。唯一在其他地方改变ptr->data的就是swap操作,而此刻已经没有其他线程可以访问者节点了,这就是比较/交换操作的全部。

如果比较/交换失败,在下次循环之前 old_head获取到的新值都不会被碰触。而且你已经决定在increase_head_count()函数中使用std::memory_order_acquire序列,这就足够了,所以此处的比较/交换操作完全可以使用std::memory_order_relaxed序列。

那么其他线程呢?你是否需要什么强制手段确保其他线程安全呢?答案是不需要,因为head只被比较/交换操作修改。因为有很多“读-改-写”操作,它们都是以push函数中的release操作为开头的顺序操作的一部分。因此,push中的compare_exchange_weak()操作同步于increase_head_count()函数中的 compare_exchange_strong()操作,它读到了存储的数据,尽管这期间有很多其他线程在在修改head。

所以,你几乎完成了:剩下的唯一需要处理的操作就是修改引用计数的fetch_add()操作。已经获取到这个节点的线程就可以安心的继续处理数据了,因为此时已经知道其他的线程无法再访问这个节点。尽管如此,其他的没有获取到这个节点的线程也知道数据被别的线程修改了(使用swap将数据提取出来)。因此,你需要确保swap一定要发生在delete之前。最简单的办法是在获取节点执行成功的分支中使用std::memory_order_release来控制fetch_add(),而在获取节点失败仍需进入下一个循环的分支中使用std::memory_order_acquire来控制fetch_add()。但这样的强制排序又有点过分了:只有一个线程会去删除节点(将count设置为0的那个线程),所以只有那个线程需要使用acquire操作。还好,fetch_add()函数是一个“读-改-写”的操作,是release序列的一部分,所以你可以附加一个load()操作。如果再循环分支将引用计数减少到0,它可以使用 std::memory_order_acquire重新读取引用计数,以确保形成synchronizes-with关系,而fetch_add()自己则使用 std::memory_order_relaxed 序列。最后的stack实现如下:

Listing 7.12 A lock-free stack with reference counting and relaxed atomic operations

template<typename T>
class lock_free_stack
{
private:
	struct node;
	struct counted_node_ptr
	{
		int external_count;
		node* ptr;
	};
	struct node
	{
		std::shared_ptr<T> data;
		std::atomic<int> internal_count;
		counted_node_ptr next;
		node(T const& data_) : data(std::make_shared<T>(data_)),internal_count(0)
		{}
	};
	std::atomic<counted_node_ptr> head;
	void increase_head_count(counted_node_ptr& old_counter)
	{
		counted_node_ptr new_counter;
		do
		{
			new_counter = old_counter;
			++new_counter.external_count;
		} while (!head.compare_exchange_strong(old_counter, new_counter,
			std::memory_order_acquire,std::memory_order_relaxed));
		old_counter.external_count = new_counter.external_count;
	}
public:
	~lock_free_stack()
	{
		while (pop());
	}
	void push(T const& data)
	{
		counted_node_ptr new_node;
		new_node.ptr = new node(data);
		new_node.external_count = 1;
		new_node.ptr->next = head.load(std::memory_order_relaxed)
			while (!head.compare_exchange_weak(new_node.ptr->next, new_node,
				std::memory_order_release,std::memory_order_relaxed));
	}
	std::shared_ptr<T> pop()
	{
		counted_node_ptr old_head = head.load(std::memory_order_relaxed);
		for (;;)
		{
			increase_head_count(old_head);
			node* const ptr = old_head.ptr;
			if (!ptr)
			{
				return std::shared_ptr<T>();
			}
			if (head.compare_exchange_strong(old_head, ptr->next, std::memory_order_relaxed))
			{
				std::shared_ptr<T> res;
				res.swap(ptr->data);
				int const count_increase = old_head.external_count - 2;
				if (ptr->internal_count.fetch_add(count_increase,std::memory_order_release) == -count_increase)
				{
					delete ptr;
				}
				return res;
			}
			else if (ptr->internal_count.fetch_add(-1, std::memory_order_relaxed) == 1)
			{
				ptr->internal_count.load(std::memory_order_acquire);//能保证执行到这步吗???
				delete ptr;
			}
		}
	}
};

这个程序经过我们的仔细修改,现在已经使用了尽可能的多的relaxed操作。但是同时,我们也看到,pop()函数较之前的版本相比多了很多行代码。就像你将在下面的无锁队列中看到的一样,大部分的复杂部分都来自于内存管理。

7.2.6 编写一个线程安全的无锁队列

一个队列比stack多了少许挑战,因为push和pop操作不同的部分。因此,同步手段也不同。你需要确保对一端的修改可以正确的被另一端的访问可见。但是,6.6中的try_pop和7.2中的pop()相差并不是很大,所以你可以假定无锁代码中的这部分也差不多,让我们来看一看。

如果将6.6程序做为基础,那么就需要2个指针:一个头,一个尾。因为要被多线程访问,所以需要使用原子变量来定义它们。让我们稍作改动,看看会怎样:

Listing 7.13 A single-producer, single-consumer lock-free queue

template<typename T>
class lock_free_queue
{
private:
	struct node
	{
		std::shared_ptr<T> data;
		node* next;
		node() :
			next(nullptr)
		{}
	};
	std::atomic<node*> head;
	std::atomic<node*> tail;
	node* pop_head()
	{
		node* const old_head = head.load();
		if (old_head == tail.load())//(1)
		{
			return nullptr;
		}
		head.store(old_head->next);
		return old_head;
	}
public:
	lock_free_queue() :
		head(new node), tail(head.load())
	{}
	lock_free_queue(const lock_free_queue& other) = delete;
	lock_free_queue& operator=(const lock_free_queue& other) = delete;
	~lock_free_queue()
	{
		while (node* const old_head = head.load())
		{
			head.store(old_head->next);
			delete old_head;
		}
	}
	std::shared_ptr<T> pop()
	{
		node* old_head = pop_head();
		if (!old_head)
		{
			return std::shared_ptr<T>();
		}
		std::shared_ptr<T> const res(old_head->data);//(2)
		delete old_head;
		return res;
	}
	void push(T new_value)
	{
		std::shared_ptr<T> new_data(std::make_shared<T>(new_value));
		node* p = new node;//(3)
		node* const old_tail = tail.load();//(4)
		old_tail->data.swap(new_data);//(5)
		old_tail->next = p;//(6)
		tail.store(p);//(7)
	}
};

咋一看上去好像还不坏。如果只有一个线程pop,而且只有一个线程push,那么就非常完美。这种情况下最重要的是在push和pop之间有一个happens-before关系。向tail中存储(7)同步于从tail中读取(1);对于指针所指向数据的存储(5)sequenced before于对tail的存储;对tail的读取sequenced before于对data指针的读取(2),所以数据的存储happens before于数据的读取,一切都ok。这是个完美的“单一生产,单一消费”队列。

问题产生于,当多个线程同时调用pop(),或者多个线程同时调用push()时。先看push(),如果有2个线程同时调用push(),它们都new了一个傀儡节点(3),都读取了相同的tail的值(4),同时更新data和next,这已经产生了数据竞争!

pop_head()也有类似的问题。如果两个线程同时调用,那么它们同时读取了相同的head,同时使用next更新老的head。两个线程都认为已经得到了head,这是灾难的开始。你不仅要保证只有一个线程pop出数据项,还要保证其他线程可以安全的访问从head得到的下一个节点。这就是问题所在。

假设pop问题解决了,那么push函数呢?它的问题是如何得到 push()和pop()之间的happens-before关系,你需要在更新tail之前设置傀儡节点的数据项。但是意味着多线程同时调用push时会出现数据竞争,因为它们读取到了同一个tail指针。

为push()处理多线程

一种选择是在2个真实节点之间添加1个傀儡节点。这样,tail中唯一需要更新的就是next部分,可以把这个变为原子操作。如果一个线程成功的把next从nullptr更新为新节点,那么就成功增加了一个指针;否则,它就需要重新读取tail重新更新。这就需要对pop做出些改变,以便于抛弃带有空指针的节点,循环处理。代价就是每次执行pop都需要删除两个节点,还有就是队列需要原来2倍的内存分配。

第二种选择是令数据指针成为原子类型,然后使用比较/交换操作去更新它。如果操作成功,那么这个tail节点归你管理,你可以安全的设置它的next指针,然后更新tail指针。如果由于其他线程抢先获取到tail节点,那么就需要循环重新读取tail。如果在std::shared_ptr<>上的原子操作是原子的,那么就好办了。如果不是那就需要想别的办法。一种可能的办法是让pop返回std::unique_ptr<>(以保证它是唯一指向数据的指针),这样就可以在队列中保存一个data的普通指针,这将允许把它保存为std::atomic<T*>,以便支持compare_exchange_strong()操作。如果在pop中使用了程序7.11中的引用计数器,那么push看起来就是这样的:

Listing 7.14 A (broken) first attempt at revising  push()

void push(T new_value)
{
	std::unique_ptr<T> new_data(new T(new_value));
	counted_node_ptr new_next;
	new_next.ptr = new node;
	new_next.external_count = 1;
	for (;;)
	{
		node* const old_tail = tail.load();//(1)
		T* old_data = nullptr;
		if (old_tail->data.compare_exchange_strong(
			old_data, new_data.get()))//(2)
		{
			old_tail->next = new_next;
			tail.store(new_next.ptr);//(3)
			new_data.release();
			break;
		}
	}
}

使用引用计数机制防止了数据竞争。但是还有。得到一个原子指针(1),然后解引用(2)。此时别的线程可能已经修改了tail的指向(3),最后导致节点被回收(在pop中)。如果在你解引用指针前节点被回收,将会导致未定义行为。这里有个诱人的方案,就是像head的那样给tail也增加一个计数器,但是每个节点的next已经是带有一个计数器的结构体,next都指向下一个节点。拥有两个计数器的节点需要对引用计数做些修改,以防止过早删除节点。解决这个问题的方法是:在node节点结构内部记录计数,而且当每个外部计数器销毁时减少这个计数(将外部计数添加到内部)。当内部计数为0时,而且没有外部计数器了,那么此时节点可以安全删除了。下面就是使用这种方案的push函数的实现:

Listing 7.15 Implementing  push() for a lock-free queue with a reference-counted  tail

template<typename T>
class lock_free_queue
{
private:
	struct node;
	struct counted_node_ptr
	{
		int external_count;
		node* ptr;
	};
	std::atomic<counted_node_ptr> head;
	std::atomic<counted_node_ptr> tail;//(1)
	struct node_counter
	{
		unsigned internal_count : 30;
		unsigned external_counters : 2;//(2)
	};
	struct node
	{
		std::atomic<T*> data;
		std::atomic<node_counter> count;//(3)
		counted_node_ptr next;
		node()
		{
			node_counter new_count;
			new_count.internal_count = 0;
			new_count.external_counters = 2;//(4)
			count.store(new_count);
			next.ptr = nullptr;
			next.external_count = 0;
		}
	};
public:
	void push(T new_value)
	{
		std::unique_ptr<T> new_data(new T(new_value));
		counted_node_ptr new_next;
		new_next.ptr = new node;
		new_next.external_count = 1;
		counted_node_ptr old_tail = tail.load();
		for (;;)
		{
			increase_external_count(tail, old_tail);//(5)
			T* old_data = nullptr;
			if (old_tail.ptr->data.compare_exchange_strong(//(6)
				old_data, new_data.get()))
			{
				old_tail.ptr->next = new_next;
				old_tail = tail.exchange(new_next);
				free_external_counter(old_tail);//(7)
				new_data.release();
				break;
			}
			old_tail.ptr->release_ref();
		}
	}
};

tail和head一样,都有atomic<counted_node_ptr>(1),node跟以前相比不再含有 internal_count,而是含有一个count成员(3)。这个count在含有一个internal_count以及一个附加的external_counters(2)。 external_counters只需要2个二进制位,因为外部计数最多只有2个。而内部计数使用了30位,这就保证了内部计数足够大,而且这个结构体可以放到一个机器字中(32位或者64位)。一件事很重要,就是要将这两个计数当做一个整体同时更新,防止数据竞争,马上你就知道怎么回事了。保持一个结构体处于一个机器字中可以使它的原子操作在很多平台上可能成为无锁操作。

node初始时将内部计数设为0,外部计数设为2(4),因为每一个节点一旦被增加到队列,那么开始时都被上一个节点的next和tail引用。push函数和7.14程序很相似,除了你在对tail的data成员使用 compare_exchange_strong()之前(6),你调用了一个新函数 increase_external_count()以增加计数(5),后来又在old_tail上调用了free_external_counter()函数。

下面看看pop函数,它夹杂着来自7.11程序中的pop中的引用计数的实现,以及来自7.13程序中的队列pop逻辑。

Listing 7.16 Popping a node from a lock-free queue with a reference-counted tail

template<typename T>
class lock_free_queue
{
private:
	struct node
	{
		void release_ref();
	};
public:
	std::unique_ptr<T> pop()
	{
		counted_node_ptr old_head = head.load(std::memory_order_relaxed);//(1)
		for (;;)
		{
			increase_external_count(head, old_head);//(2)
			node* const ptr = old_head.ptr;
			if (ptr == tail.load().ptr)
			{
				ptr->release_ref();//(3)
				return std::unique_ptr<T>();
			}
			if (head.compare_exchange_strong(old_head, ptr->next))//(4)
			{
				T* const res = ptr->data.exchange(nullptr);
				free_external_counter(old_head);//(5)
				return std::unique_ptr<T>(res);
			}
			ptr->release_ref();//(6)
		}
	}
};

在开始循环以及增加外部引用计数(2)之前先读取old_head(1)。如果head和tail想等,那么就释放引用(3),并且返回空指针,因为队列中没有数据。如果队列中有数据,那么就使用compare_exchange_strong()将其取出(4)。就像7.11中的stack那样,这一步将比较外部引用计数和指针当成了一个整体操作;任何一样改变了都要释放引用(6),然后重新循环。如果交换成功,那么你就提取出了这个节点,然后在释放外部引用计数(5)后将数据返回给调用者。一旦外部计数都被释放了,而且内部计数等于0了,那么就可以删除节点了。引用计数相关的函数在下面的7.17、7.18、7.19程序中列出:

Listing 7.17 Releasing a node reference in a lock-free queue

template<typename T>
class lock_free_queue
{
private:
	struct node
	{
		void release_ref()
		{
			node_counter old_counter =
				count.load(std::memory_order_relaxed);
			node_counter new_counter;
			do
			{
				new_counter = old_counter;
				--new_counter.internal_count;//(1)
			} while (!count.compare_exchange_strong(//(2)
				old_counter, new_counter,
				std::memory_order_acquire, std::memory_order_relaxed));
			if (!new_counter.internal_count &&
				!new_counter.external_counters)
			{
				delete this;//(3)
			}
		}
	};
};

尽管现在只想更新internal_count(1),但是count数据结构必须被看成一个整体来操作,所以使用了compare_exchange_strong()而不是fetch_add()(2)。一旦令内部计数自减,就去检查内部计数和外部计数是否都为0,如果是就删除节点(3)。

Listing 7.18 Obtaining a new reference to a node in a lock-free queue

template<typename T>
class lock_free_queue
{
private:
	static void increase_external_count(std::atomic<counted_node_ptr>& counter,
		counted_node_ptr& old_counter)
	{
		counted_node_ptr new_counter;
		do
		{
			new_counter = old_counter;
			++new_counter.external_count;
		} while (!counter.compare_exchange_strong(
			old_counter, new_counter,
			std::memory_order_acquire, std::memory_order_relaxed));
		old_counter.external_count = new_counter.external_count;
	}
};

这个函数与7.12中的increase_head_count几乎一样,除了改成了静态函数。

Listing 7.19 Freeing an external counter to a node in a lock-free queue

template<typename T>
class lock_free_queue
{
private:
	static void free_external_counter(counted_node_ptr &old_node_ptr)
	{
		node* const ptr = old_node_ptr.ptr;
		int const count_increase = old_node_ptr.external_count - 2;
		node_counter old_counter =
			ptr->count.load(std::memory_order_relaxed);
		node_counter new_counter;
		do
		{
			new_counter = old_counter;
			--new_counter.external_counters;//(1)
			new_counter.internal_count += count_increase;//(2)
		} while (!ptr->count.compare_exchange_strong(old_counter, new_counter,//(3)
			std::memory_order_acquire, std::memory_order_relaxed));
		if (!new_counter.internal_count &&
			!new_counter.external_counters)
		{
			delete ptr;//(4)
		}
	}
};

这个函数的功能是在7.11程序lock_free_stack::pop()函数基础上对外部计数的处理做了改动。它使用 compare_exchange_strong()对count结构体中的两个计数同时做了修改(3)。internal_count就像7.11那样被增加(2), external_counters自减1(1)。如果二者都为0,则可以安全删除节点。如果对count内部的两个计数的修改是分开操作的,那么就有可能存在这样的情况:两个线程都以为自己是最后一个处理这个节点的线程,都去删除这个节点,出现问题。

尽管现在没有数据竞争了,但是仍然存在一个性能问题。一旦一个线程已经开始执行push函数,并且成功的对old_tail.ptr->data执行了compare_exchange_strong()操作(7.15程序中的(5)),那么别的线程就都无法执行push()操作了。任何别的线程试图比较/交换时都会发现data中已经从nullptr换成了新的指针,然后只能继续循环。这是一种忙等,这会消耗CPU的周期,而且什么也没做。因此,这实际上是一种锁。第一个执行push的线程将其他线程都阻塞了,直到它完成,所以这种代码已经不是无锁的了。不仅如此,如果有线程被阻塞了,操作系统会优先处理持有锁的线程。但是现在这种情况操作系统却无能为力。所以,被阻塞的线程会一直浪费CPU周期,直到第一个线程完成push()。这就需要下一个技巧,无锁包:等待的线程可以帮助正在执行push操作的线程。

通过线程互助实现无锁队列

为了恢复代码的无锁特性,你需要寻找一种方法,即使正在执行push的线程止步不前也要让等待线程继续工作。一种办法就是帮助进展缓慢的线程做事。

在这种情况下,你明确知道需要做什么:tail节点的next指针需要被设置为一个新的傀儡节点,然后tail指针自己需要被更新。傀儡节点的特点就是它们都一样,不管是执行push的线程还是等待的线程去创建都无所谓。如果你把next指针变为原子类型,那么就可以使用compare_exchange_strong()来设置它的值。一旦next被设置了,当tail还是引用了原来的节点时,那就可以使用一个compare_exchange_weak()循环去设置tail。如果tail已经改变了,那就是和别的线程已经更新它了,你就可以停止尝试,然后继续循环了。这要求对push()做很小的改动,为了读取next指针,看下面:

Listing 7.20 pop() modified to allow helping on the  push() side

template<typename T>
class lock_free_queue
{
private:
	struct node
	{
		std::atomic<T*> data;
		std::atomic<node_counter> count;
		std::atomic<counted_node_ptr> next;//(1)
	};
public:
	std::unique_ptr<T> pop()
	{
		counted_node_ptr old_head = head.load(std::memory_order_relaxed);
		for (;;)
		{
			increase_external_count(head, old_head);
			node* const ptr = old_head.ptr;
			if (ptr == tail.load().ptr)
			{
				return std::unique_ptr<T>();
			}
			counted_node_ptr next = ptr->next.load();//(2)
			if (head.compare_exchange_strong(old_head, next))
			{
				T* const res = ptr->data.exchange(nullptr);
				free_external_counter(old_head);
				return std::unique_ptr<T>(res);
			}
			ptr->release_ref();
		}
	}
};

改动很简单:next指针改为原子类型(1),所以读取成为原子操作(2)。在这例子中,使用了缺省的memory_order_seq_cst序列。

push()函数则更复杂一些:

Listing 7.21 A sample  push() with helping for a lock-free queue

template<typename T>
class lock_free_queue
{
private:
	void set_new_tail(counted_node_ptr &old_tail,counted_node_ptr const &new_tail)//(1)
	{
		node* const current_tail_ptr = old_tail.ptr;
		while (!tail.compare_exchange_weak(old_tail, new_tail) && old_tail.ptr == current_tail_ptr);//(2)
		if (old_tail.ptr == current_tail_ptr)//(3)
			free_external_counter(old_tail);//(4)
		else
			current_tail_ptr->release_ref();//(5)
	}
public:
	void push(T new_value)
	{
		std::unique_ptr<T> new_data(new T(new_value));
		counted_node_ptr new_next;
		new_next.ptr = new node;
		new_next.external_count = 1;
		counted_node_ptr old_tail = tail.load();
		for (;;)
		{
			increase_external_count(tail, old_tail);
			T* old_data = nullptr;
			if (old_tail.ptr->data.compare_exchange_strong(old_data, new_data.get()))//(6)
			{
				counted_node_ptr old_next = { 0 };
				if (!old_tail.ptr->next.compare_exchange_strong(old_next, new_next))//(7)
				{
					delete new_next.ptr;//(8)
					new_next = old_next;//(9)
				}
				set_new_tail(old_tail, new_next);
				new_data.release();
				break;
			}
			else//(10)
			{
				counted_node_ptr old_next = { 0 };
				if (old_tail.ptr->next.compare_exchange_strong(old_next, new_next))//(11)
				{
					old_next = new_next;//(12)
					new_next.ptr = new node;//(13)
				}
				set_new_tail(old_tail, old_next);//(14)
			}
		}
	}
};

它与7.15中的push()函数很相似,但是它有一席重要的不同之处。如果你设置了数据指针(6),那么你需要处理另外的情况,在else分支别的线程帮你做了一些事(10)。

一旦设置了数据的指针(6),则使用compare_exchange_strong()(使用这个函数防止循环)去跟新next指针(7)。如果交换失败了那么你知道别的现场一斤更新了next指针,所以需要删除起初new的node(8)。然后你也需要别的线程为next指针更新的值(9)。

实际上对tail指针的更新已经被提取到了set_new_tail()函数中(1)。它使用 compare_exchange_weak()更新tail(2),因为如果其他线程试图执行push()函数,那么 external_count可能会被改变,而且你不想错过当前tail。但你仍要小心:如果别的线程已经成功更新它了,那么你不能替换它;否则,你将结束循环,那不是一个好主意。所以你需要在比较/交换操作失败时确定得到的tail中的ptr部分是否原来的值,如果循环结束了,而且ptr一样(3),那么你一定成功的设置了tail,所以要释放外部计数(4)。如果ptr已经不同了,那么其他的线程将释放外部计数,那你只需要释放当前线程持有的单个引用(5)。

如果调用push()的线程在循环中本次对tail中的ptr->data指针的设置失败了,那么它可以帮助成功的线程完成更新。首先,尝试使用在本线程中创建的节点设置tail.ptr->next(11)。如果成功,那你将希望使用你创建的节点作为新的tail节点(12),而且你还要再创建一个新的节点作为这个新节点的下一个节点,以管理新队列新增加的节点(13)。然后再进入下一次循环前使用set_new_tail尝试为tail设置新值(14)。

你可能已经注意到了,在这么一小段代码中使用了很多的new和delete,原因是,在push中new,在pop中delete。内存分配会对这段代码的性能造成相当大的影响,糟糕的分配器可能会完全毁掉无锁容器的扩展性。对分配器的选择和实现已经操出了本书的范围,但是很重要的一点是:唯一判断一个分配器的好坏的方法就是对比测试使用前和使用后的效果。优化内存分配器的通常技术包括:为每一个线程布置一个内存分配器,而且使用回收list回收节点,而不是把它们直接返回给分配器。

例子就讲这么多,现在看看从中能得到什么关于编写无锁数据结构的指导方针。

7.3 编写无锁数据结构的指导方针

如果你完整的看完了这一章,那么你将了解到无锁编程的复杂性。如果你将要设计自己的数据结构,那么需要注意一些事。关于编写普通的并行数据结构的关键点已经在第6章中说明了,但是对于无所编程还要注意更过。下面将从这些例子中提取一些有用的指导建议。

7.3.1 使用std::memory_order_seq_cst作为设计原型

因为所有指定td::memory_order_seq_cst的操作形成一个总的顺序,所以它比其他的内存序列更为简单。所有的例子都是首先使用std::memory_order_seq_cst,当所有的操作都能正常工作了后才放宽内存序列限制。在这种情况下,使用其他内存序列是一种优化,所以要避免过早的使用其他序列。通常,只有当你看清楚整个数据结构的操作后,你才能确定哪个操作可以放宽内存序列的限制。否则,只能使事情变得更糟糕。事实上,即使代码测试通过,也不能保证完全正确。除非你有一个算法可以系统测试线程所有可见性的组合(这样的算法确实存在),否则,仅仅运行代码是不够的。

7.32 使用无锁内存回收机制

无锁代码的最大不同在于管理内存。避免删除一个可能被其他线程正持有引用的对象是很重要的,但是你又希望尽快清除对象以避免内存消耗。这里有3个保证安全回收内存的准则:

■ 等到所有线程都不再访问数据结构时再删除待删除的数据

■ 使用风险指针标识出哪个线程正在访问指定对象

■ 使用引用计数确保在删除对象时保证没有外部线程持有这个对象的引用

在所有情况下,关键是要保证多个线程使用一种方式去访问指定的对象,只有在没有线程引用这个对象时才删除对象。还有很多在无锁数据结构中回收内存的方法。例如,这是使用垃圾回收器的理想场景。如果你知道垃圾回收装置可以在节点不被使用时回收它们的话,那么很容易根据这点写一个算法。

还有一种办法就是使用循环节点,只有在数据结构被销毁时才去回收它们。由于节点被重用,内存永远不会变成无效,所以之前的难点也接触了。缺点是另外一个问题变得很普遍了,那就是所谓的ABA问题。

7.3.3 小心ABA问题

ABA是一种困扰着所有基于比较/交换算法的问题,它的产生过程就像下面这样:

1.线程1读取了原子变量x,得到值A

2.线程1基于这个值做了一些操作,例如解引用(如果x是一个指针的话),或者进行查找。

3.线程1被操作系统挂起。

4.另一个线程使用某种操作将x的值改为B

5.某个线程改变了A关联的数据,使得线程1持有的值已经无效。如果是释放了指针指向的内存或者只是改变了其关联的数据都是个大问题。

6.某个线程将改变数据后的A重新赋值给x。如果这是一个指针,那么现在具有新值的A与原来的A所指向的对象仅仅是共享了同一个内存地址。

7.线程1从挂起状态恢复,然后对x执行一个比较/交换操作,使用A作为比较条件。比较/交换操作成功了(因为x中的值的确是A),但是此A非彼A,A指向的早已不是之前的那个对象了。从第2步读取的数据已经不再有效,但是线程1无从得知,这将破坏数据结构。

本章提出的算法都不存在这个问题,但是编写无锁算法很容易出现这个问题。避免这个问题的方法是为变量x提供一个ABA计数器。然后在由x和计时器构成的独立单元上执行比较/交换操作。每次数值被替换,计数就自增,所以即使x存有同一个对象,如果其他线程改变了x,比较/交换操作也会失败。

ABA问题很普遍的存在于,使用释放链表或者其他循环节点的算法而不是将节点直接返还给分配器的算法中。

7.3.4 识别出忙等循环,帮助其他线程

最后一个例子演示了其他线程帮助正在push的线程。

7.4 总结

一旦需要在线程间共享数据,那么通过设计并行数据结构可以将责任压缩到数据结构内部,外部的代码就可以不用顾忌并行相关的问题而将注意力放在业务上。





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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值