C和C++安全编码笔记:并发

并发是一种系统属性,它是指系统中几个计算同时执行,并可能彼此交互。一个并发程序通常使用顺序线程和(或)进程的一些组合来执行计算,其中每个线程和进程执行可以在逻辑上并行执行的计算。这些进程和(或)线程可以在单处理器系统上使用分时抢占式的方式(用一种时间分片的方法使每个线程和(或)进程中的执行步骤交错进行)、在多核/多处理器系统中,或者在一个分布式计算系统中执行。多个控制流并发执行是现代计算环境的重要组成部分。

7.1 多线程:多线程不一定是并发的。一个多线程程序可以以这样一种方式构建,即它的线程不会并发执行。

一个多线程程序分成可以并发执行的两个或更多线程。每个线程都作为一个单独的程序,但所有线程都在相同的内存中工作,并共享相同的内存。此外,线程之间的切换速度比进程间切换更快。最后,多个线程可以在多个CPU上并行执行,以提高性能收益。

即使没有多个CPU,现在CPU架构的改进,也可以允许同时多线程,即在相同的内核中使交织在一起的多个独立线程同时执行。英特尔把这个过程称为超线程(hyperthreading)。无论CPU的数量是多少,线程安全都必须加以处理,以避免可能因执行次序而产生的潜在灾难性bug。一个单线程的程序是完全不会产生任何额外线程的程序。因此,单线程程序通常并不需要担心同步,并可以受益于强大的单核处理器。然而,即使是单线程程序也可能有并发问题。

char* err_msg;
#define MAX_MSG_SIZE 24

void handler(int signum)
{
	strcpy(err_msg, "SIGINT encountered.");
}

int test_secure_coding_7_1()
{
	// 即使是单线程程序也可能有并发问题
	// 虽然此程序只使用了一个线程,但它采用了两个控制流:一个使用test_secure_coding_7_1函数,另一个使用handler函数
	// 如果在调用malloc()的过程中调用信号处理程序,该程序可能奔溃,如在程序执行5秒内,按Ctrl+C键
	// 在信号处理函数中只调用异步安全的函数
	signal(SIGINT, handler);
	std::this_thread::sleep_for(std::chrono::seconds(5));
	err_msg = (char*)malloc(MAX_MSG_SIZE);
	if (err_msg == nullptr) { // 处理错误条件
		fprintf(stderr, "fail to malloc\n");
		return -1;
	}

	strcpy(err_msg, "No errors yet.");
	// 主代码循环
	fprintf(stdout, "err_msg: %s\n", err_msg);
	return 0;
}

7.2 并行:所有的并行程序都是并发的,但不是所有的并发程序都是并行的这意味着并发程序既可以用交错、时间分片的方式执行又可以并行执行

并行计算是”同时使用多台电脑资源解决计算问题”。把问题分解成几部分,再细分成一系列指令。然后来自各部分的指令在不同的CPU并行运行,实现并行计算。每个部分都必须独立于其它部分并可同时解,最终的结果是多个CPU比单个CPU可以在更短的时间内解决问题。

并行包括数据并行(data parallelism)和任务并行(task parallelism)。这些因问题分解的程度而异。数据并行可用于在比顺序处理更短的时间内处理计算单元,它是高性能计算的基础。单指令多数据(Single Instruction, Multiple Data, SIMD)是一类具有多个处理单元,同时对多个数据点执行相同操作的并行计算机。支持SIMD的CPU的例子有包括SIMD流指令扩展(Stream SIMD Extension, SSE)的Intel或AMD处理器和包括NEON指令的ARM处理器。任务并行性指将一个问题分解成可以共享数据的不同任务。各任务在同一时间执行,但执行不同的功能。因为这种类型的并行性的任务数量是固定的,所以它具有有限的可扩展性。它由主流操作系统和多种编程语言支持,一般用来提高程序的响应能力。

7.3 性能目标:除了并行计算的概念,术语并行度(parallelism)用来表示工作(所有指令花费的总时间)跨度(执行最长的并行执行路径或关键路径所花费的时间)比。所得到的值是沿关键路径的每个步骤完成的平均值,并且是任意数量的处理器可能获得的最大加速比。因此,可实现的并行度受限于程序结构,依赖于它的关键路径和工作量。能够并行执行的计算越多,优势就越大。这种优势有一个上限,这个上限近似于工作跨度比。

7.4 常见错误:

竞争条件:不受控制的并发可能会导致不确定的行为(即对相同的一组输入,一个程序可能表现出不同的行为)。在任何情况下,取决于哪个线程首先完成,只要两个线程可以产生不同的行为,都会产生竞争条件。

竞争条件的存在离不开三个属性

(1).并发属性:至少有两个必须同时执行的控制流。

(2).共享对象属性:两个并发流都必须访问一个共享的竞争对象。

(3).改变状态属性:至少有一个控制流一定会改变竞争对象的状态。

竞争条件是一种软件缺陷,并且经常是漏洞的来源。竞争条件特别阴险,因为它们有时间依赖性并且是零星出现的。因此,它们难以察觉、重现和消除,并可能导致错误,如数据损坏或崩溃。竞争条件是运行时环境导致的,这个运行时环境包括必须对共享资源的访问进行控制的操作系统,特别是通过进程调度进行控制的。无论运行时环境如何调度执行(在已知的限制条件下),确保代码正确排序都是程序员的责任。

要消除竞争条件,首先要识别竞争窗口。竞争窗口是访问竞争对象的一个代码段,它的执行方式是打开一个机会窗口,在此期间其它并发流可以”竞争进入”,并改变竞争对象。此外,竞争窗口不受锁或任何其它机制保护。用锁或无锁的机制保护的竞争窗口称为临界区(critical section)。

损坏的值:在竞争条件下写入的值很容易损坏。防止此类数据损坏最常见的缓解措施是使变量成为原子类型。

易变的对象:具有volatile限定类型的对象,可能以编译器未知的方式修改,或者有其它未知的副作用。例如,异步信号处理,可能会导致以编译器未知的方式修改对象。volatile类型限定符对访问和缓存施加限制。根据C的标准:volatile对象的访问严格按照抽象机的规则进行评估。在省略volatile限定符的情况下,除了可能的别名,可以假定指定位置的内容是不变的。对不能缓存的数据使用volatile当一个变量被声明为volatile时,就会禁止编译器对该内存位置的读取和写入顺序进行重新排列。但是,编译器可能对这些读取、写入和对其它的内存位置的读取和写入的相对顺序进行重新排列。具有volatile类型限定符的对象不保证多个线程之间的同步,不防止并发内存访问,也不保证对对象的原子性访问。

volatile sig_atomic_t interrupted; // 应声明为volatile

void sigint_handler(int signum)
{
	interrupted = 1; // 赋值可能是在test_secure_coding_7_4不可见的
	fprintf(stdout, "interrupted'value is changed\n");
}

int test_secure_coding_7_4()
{
	signal(SIGINT, sigint_handler);

	// 执行后可同时按下ctrl+c键停止
	while (!interrupted) { // interrupted若不声明为volatile的,循环可能永远不会终止
		// do something
	}

	return 0;
}

7.5 缓解策略:为了在C和C++语言中支持并发,许多库与特定于平台的扩展已开发出来。一个常见的库是POSIX线程库(pthread)。2011年,C和C++的ISO/IEC新版本标准公布了,两者都提供了对多线程程序的支持。把线程的支持集成到语言中比起分别通过库提供线程有几大优势。为了保持最大的兼容性,C的线程支持派生自C++的线程支持,只做了句法的变化以支持C语言更简单的语法。C++的线程支持使用类和模板。

内存模型:C和C++的多线程使用相同的内存模型,这是从Jave派生的(有一些变化)。一个标准化的线程平台的内存模型比以前的内存模型要复杂得多。C/C++的内存模型必须提供线程安全性,同时仍然允许细粒度访问硬件,特别是一个平台可能会提供的任何低级别的线程原语。

编译器重新排序:在重组程序方面,编译器具有非常大的自由度。如果规则授权编译器改变一个程序指令的顺序。不是用来对多线程程序进行编译的编译器可能会在程序中采用”仿佛”规则,仿佛程序是单线程的。如果程序使用一个线程库,如POSIX线程序,那么事实上,编译器可能把线程安全的程序改造成非线程安全的程序。

数据竞争(Data Race):如果某个程序在不同的线程中包含两个相互矛盾的动作,其中至少有一个不是原子的,并且两者都不在另一个之前发生,那么执行这个程序包含数据竞争。任何这样的数据竞争都会导致未定义的行为。如果两个表达式中的一个修改某一内存位置,而另一个读取或修改相同的内存位置,那么这两个表达式求值发生冲突。与竞争条件不同,数据竞争专门指内存访问,并可能不适用于其它共享对象,如文件。

同步原语:为了防止数据的竞争,对同一对象执行的任何两个动作,必须有一个”发生在之前”关系。操作的具体顺序是无关紧要的。这种关系不但建立动作之间的时间顺序,而且也保证了第一个动作改变的内存对第二个动作是可见的。可以使用同步原语(synchronization primitive)建立一个”发生在之前”的关系。C和C++都支持几种不同类型的同步原语,包括互斥变量(mutex variable)、条件变量(condition variable)和锁变量(lock variable)。底层操作系统还支持额外的同步原语,如信号量(semaphore)、管道(pipe)、命名管道(named pipe)和临界区对象(critical section object)。在竞争窗口之前获取同步对象,然后在窗口结束后释放它,使竞争窗口中关于使用相同的同步机制的其它代码是原子的。竞争窗口最终成为一个代码临界区。所有临界区对执行临界区的线程以外的所有适当的同步线程都是原子的。

防止临界区并发执行存在许多策略。这些策略中的大多数涉及锁机制,锁机制导致一个或多个线程等待,直到另一个线程退出临界区

互斥量(mutex):最简单的一种锁机制是称为互斥量的一个对象。互斥量有两种可能的状态:锁定和解锁。一个线程锁定一个互斥量后,任何后续试图锁定该互斥量的线程都将被阻止,直到此互斥量被解锁为止。当互斥量解锁后,阻塞线程可以恢复执行,并锁定互斥量以继续。此策略可确保一次只有一个线程可以运行花括号内的代码。因此,互斥量可以包装在临界区,以使它们序列化,从而使程序是线程安全的互斥量不与任何其它数据关联。它们只是作为锁对象

C++11中的<mutex>:当对已经锁定的互斥量执行lock()操作时,该函数会被阻塞直到当前持有该锁的线程释放它。try_lock()方法试图锁定互斥量,但如果该互斥量已经锁定,它就立即返回,以允许线程执行其它操作。C++还支持定时的互斥量,它提供try_lock_for()和try_lock_until()方法。这些方法会被阻塞,直到互斥量成功锁定或经过指定长度的时间。所有其它方法的行为与普通的互斥量相同。C++还支持递归互斥量。这些互斥量的行为也像普通的互斥量一样,除了它们允许单个线程不止一次地获取锁,而中间不用解锁。多次锁定一个互斥量的线程,必须解锁相同的次数,之后此互斥量才可以被任何其它线程锁定。非递归互斥量在没有干预解锁时不能被同一个线程多次锁定。最后,C++支持既是定时又是递归的互斥量。

std::mutex shared_lock;
int shared_data = 0;
void thread_function(int id)
{
	// 当对已经锁定的互斥量执行lock操作时,该函数会被阻塞直到当前持有该锁的线程释放它
	shared_lock.lock();
	shared_data = id; // shared_data的竞争窗口开始
	fprintf(stdout, "thread: %d, set shared value to: %d\n", id, shared_data);
	std::this_thread::sleep_for(std::chrono::milliseconds(id) * 100);
	fprintf(stdout, "thread: %d, has shared value to: %d\n", id, shared_data); // shared_data的竞争窗口结束	
	shared_lock.unlock();
}

void test_concurrency_mutex()
{
	const size_t thread_size = 10;
	std::thread threads[thread_size];

	for (size_t i = 0; i < thread_size; ++i)
	      threads[i] = std::thread(thread_function, i);

	for (size_t i = 0; i < thread_size; ++i)
	      threads[i].join();

	// test_concurrency_mutex()继续之前,等待直到线程完成
	fprintf(stdout, "Done\n");
}

C对互斥量的支持与C++对互斥量的支持在语义上是相同的,但具有不同的语法,因为C缺乏类和模板。C标准库提供mtx_lock()、mtx_unlock()、mtx_trylock()和mtx_timedlock()函数来锁定与解锁互斥量。它还提供mtx_init()和mtx_destroy()函数来创建与销毁互斥量。

锁卫士(Lock Guard):是承担对互斥量(实际上,任何锁定对象)的看管责任的一个标准对象。当针对一个互斥量构造锁卫士时,它试图锁定互斥量,当锁卫士本身被销毁时它解除对该互斥量的锁定。锁卫士对互斥量应用资源采集时初始化(Resource Acquisition Is Initialization, RAII)。因此,在用C++编程时,如果发生临界区抛出异常,或退出时没有明确地对互斥量解锁,我们建议使用锁卫士缓解这些问题

std::mutex shared_lock2;
int shared_data2 = 0;
void thread_function2(int id)
{
	std::lock_guard<std::mutex> lg(shared_lock2);
	shared_data2 = id; // shared_data2的竞争窗口开始
	fprintf(stdout, "thread: %d, set shared value to: %d\n", id, shared_data2);
	std::this_thread::sleep_for(std::chrono::milliseconds(id) * 100);
	fprintf(stdout, "thread: %d, has shared value to: %d\n", id, shared_data2); // shared_data2的竞争窗口结束	
	// lg被销毁,且互斥量在这里被隐式地解锁
}

void test_concurrency_mutex_guard()
{
	const size_t thread_size = 10;
	std::thread threads[thread_size];

	for (size_t i = 0; i < thread_size; ++i)
	      threads[i] = std::thread(thread_function2, i);

	for (size_t i = 0; i < thread_size; ++i)
	      threads[i].join();

	// test_concurrency_mutex_guard()继续之前,等待直到线程完成
	fprintf(stdout, "Done\n");
}

原子操作(Atomic Operation):原子操作是不可分割的。也就是说,一个原子操作不能被任何其它的操作中断,当正在执行原子操作时,它访问的内存,也不可以被任何其它机制改变。因此,必须在一个原子操作运行完成后,其它任何事物才能访问该操作所使用的内存,原子操作不能被划分成更小的部分。简单的机器指令,例如,装载一个寄存器,可能是不可中断的。被一个原子加载访问的内存位置不可以由其它任何线程访问,直到此原子操作完成。原子对象是保证它执行的所有操作都是原子的任何对象。通过对某个对象上的所有操作施加原子性,一个原子对象不会被同时读取或写入破坏。原子对象不存在数据竞争,虽然它们仍然可能会受到竞争条件的影响。C和C++对原子对象提供广泛的支持。每一个基本数据类型都具有类似的原子数据类型。

atomic_flag数据类型提供了经典的测试和设置(test-and-set)功能。它有两个状态,设置和清除。通过包括<atomic>头文件,程序可以访问原子类型和相关函数。对于每个原子类型,标准还提供了原子类型名称,如atomic_short或atomic_ulong。

volatile std::atomic_flag shared_lock3;
int shared_data3 = 0;
void thread_function3(int id)
{
	// 只有当标志在之前未设置时,atomic_flag对象的test_and_set方法才会设置标志.当标志设置成功时,test_and_set
	// 方法返回false,当标志已经设置时,它返回true.只有当整数锁以前是0时,这才与设置整数锁为1效果相同.但是,
	// 因为test_and_set方法是原子的,它缺乏这样的竞争窗口,即该窗口中的其它地方可以篡改标志.因此共享锁可以防止
	// 多个线程进入临界区,所以代码是线程安全的
	while (shared_lock3.test_and_set()) std::this_thread::sleep_for(std::chrono::seconds(1));
	shared_data3 = id; // shared_data3的竞争窗口开始
	fprintf(stdout, "thread: %d, set shared value to: %d\n", id, shared_data3);
	std::this_thread::sleep_for(std::chrono::milliseconds(id) * 100);
	fprintf(stdout, "thread: %d, has shared value to: %d\n", id, shared_data3); // shared_data3的竞争窗口结束	
	shared_lock3.clear();
}

void test_concurrency_atomic()
{
	const size_t thread_size = 10;
	std::thread threads[thread_size];

	for (size_t i = 0; i < thread_size; ++i)
	      threads[i] = std::thread(thread_function3, i);

	for (size_t i = 0; i < thread_size; ++i)
	      threads[i].join();

	// test_concurrency_atomic()继续之前,等待直到线程完成
	fprintf(stdout, "Done\n");
}

每种原子整数类型都支持装载和存储操作,以及更高级的操作。atomic_exchange()泛型函数把一个新值存储到一个原子变量,并返回变量的旧值。当且仅当目前的变量包含特定值时,atomic_compare_exchange()泛型函数才会把一个新值存储到一个原子变量中,只有当成功地改变原子变量时,函数才返回true。最后,原子整数支持读--修改--写操作,例如atomic_fetch_add()函数。这个函数类似于”+=”运算符,但有两个不同的行为。首先,它返回变量的旧值,而”+=”返回相加的总和。其次,”+=”缺乏线程安全保证。而原子提取函数承诺,当相加发生时,该变量不能被任何其它线程访问。对于减法、按位与、按位或、按位异或,存在类似的提取函数。C标准也定义了atomic_flag的类型,但它只支持两个函数:atomic_flag_clear()函数清除标志,当且仅当标志以前是清除状态时,atomic_flag_test_and_set()函数才设置标志。atomic_flag类型保证是无锁的。其它原子类型的变量可能会或可能不会以无锁的方式操纵。

std::atomic<int> shared_lock4;
int shared_data4 = 0;
void thread_function4(int id)
{
	// 锁定对象是一个可赋值为数值的原子整数.atomic_compare_exchange_weak函数安全地将锁设置为1,此函数允许意外失败.
	// 也就是说,即使当原子整数的预期值为0,它也可能没有设置为1. 出于这个原因,必须始终在一个循环内调用此函数,
	// 以便它在遇到意外失败时可以重试
	int zero = 0;
	while (!std::atomic_compare_exchange_weak(&shared_lock4, &zero, 1))
	      std::this_thread::sleep_for(std::chrono::seconds(1));
	shared_data4 = id; // shared_data4的竞争窗口开始
	fprintf(stdout, "thread: %d, set shared value to: %d\n", id, shared_data4);
	std::this_thread::sleep_for(std::chrono::milliseconds(id) * 100);
	fprintf(stdout, "thread: %d, has shared value to: %d\n", id, shared_data4); // shared_data4的竞争窗口结束	
	shared_lock4 = 0;
}

void test_concurrency_atomic2()
{
	const size_t thread_size = 10;
	std::thread threads[thread_size];

	for (size_t i = 0; i < thread_size; ++i)
	      threads[i] = std::thread(thread_function4, i);

	for (size_t i = 0; i < thread_size; ++i)
	      threads[i].join();

	// test_concurrency_atomic2()继续之前,等待直到线程完成
	fprintf(stdout, "Done\n");
}

C++标准提供了一个与C类似的API。它提供了<atomic>头文件。C++提供了一个atomic<>模板用于创建整数类型的原子版本,如atomic<short>和atomic<unsigned long>。atomic_bool的行为类似于C,并具有相似的API。C++标准支持与C相同的原子操作,但是,它们既可以用函数表示,又可以用原子模板对象的方法表示。例如,atomic_exchange()函数与C里一样工作,但被atomic<>::exchange()模板方法所取代。此外,C++提供了附加的运算符(+、-、++、--、+=、-=)的重载版本,它们使用atomic_fetch_add()和类似的函数。C++缺乏提供相应位运算功能的运算符。

围栏:内存障碍(memory barrier),也称为内存围栏(memory fence),它是一组指令,用于防止CPU和可能的编译器对隔着围栏的读取和写入操作重新排序。内存障碍是一种减缓数据竞争的低级别方法。

信号量(semaphore):类似于互斥量,但信号量也维护了一个其值在初始化时声明的计数器。因此,信号量是递减和递增的,而不是锁定和解锁的。通常情况下,在一个临界区的开头递减信号量并在临界区结束处递增信号量。当一个信号量的计数器达到0时,递减信号量的后续尝试将被阻止,直到计数器被递增。信号量的好处是,它控制当前访问由信号量把守的临界区的线程数量。对于管理资源池或协调多个线程使用单个资源,这是非常有用的。信号量计数器的初始值是将授权并发访问由该信号量把守的临界区的线程总数。需要注意的是,一个初始计数器为1的信号量的行为,就好像是一个互斥量。

无锁的方法:无锁算法提供了一种对共享数据执行操作的方法,而不用在线程之间调用系统开销大的同步函数。标准atomic_compare_exchange_weak()函数和atomic_flag::test_and_set()方法是无锁的方法。它们使用内置的互斥技术,而不是使用明确的锁对象,如互斥量,来使它们原子化。

消息队列(message queue):是一个用于线程和进程间通信的异步通信机制。消息传递并发往往比共享内存并发的推理容易得多,后者通常需要具有某种形式的锁定的应用程序(例如,互斥量、信号量或监视器)在线程之间协调。

线程角色分析(研究):许多多线程软件系统包含规范线程、可执行代码,以及可能的共享状态间的联系的策略。例如,系统可能会限制允许哪些线程执行特定的代码段,通常作为一种手段来限制那些线程对特定元素的状态读取或写入。这些线程使用策略(thread usage policy),确保如状态禁闭或读/写的限制等属性,通常没有要锁定的资源或事务原则。线程使用策略的概念不是特定于语言的。

不可变的数据结构:只有当两个或多个线程共享数据而且至少一个线程视图对数据进行修改时,才可能有竞争条件。提供线程安全的一种常用的方法是简单地防止线程修改共享数据,在本质上,即是使数据只读。保护不可改变的共享数据不需要锁。有几个技巧使共享数据只读。一旦初始化,一些类根本无法提供任何方法来修改它们的数据。可以安全地在线程之间共享这些类的对象。在C和C++中一种常见的战术是声明一个共享对象为const。另一种方法是复制一个线程可能要修改的任何对象。再次,在这种情况下,所有共享对象都是只读的,任何需要修改一个对象的线程都会创建一个共享对象的私有副本,其后只能用它的副本工作。因为副本是私有的,所以共享的对象仍然是不变的。

并发代码属性:线程安全和可重入。

线程安全:线程安全函数的使用可以帮助消除竞争条件。根据定义,一个线程安全函数通过锁或其它互斥机制来防止共享资源被并发访问。因此,一个线程安全的函数可以同时被多个线程调用,而不用担心。如果一个线程不使用静态数据或共享资源,它明显是线程安全的。然而,使用全局函数引发了线程安全的红旗,且任何对全局数据的使用必须同步,以避免竞争条件。为了使一个函数成为线程安全的,它必须同步访问共享资源。特定数据的访问或整个库可以锁定。然而,在库上使用全局锁会导致争用(contention)。

可重入:可重入(reentrant)函数也可以减轻并发编程错误。函数是可重入的,是指相同函数的多个实例可以同时运行在相同的地址空间中,而不会创建潜在的不一致的状态。IBM定义的可重入函数,是指它在连续调用时不持有静态数据,也不会返回一个指向静态数据的指针。因此,可重入函数使用的所有数据都由调用者提供,并且可重入函数不能调用不可重入函数。可重入函数可以中断,并重新进入(reentered)而不会丢失数据的完整性,因此,可重入函数是线程安全的。可重入函数一定也是线程安全的,但线程安全的函数却可能无法重入。

7.6 缓解陷阱:当并发实现得不正确时,就会产生漏洞。多线程程序中常见错误:

(1).没有用锁保护共享数据(即数据竞争)。

(2).当锁确实存在时,不使用锁访问共享数据。

(3).过早释放锁。

(4).对操作的一部分获取正确的锁,释放它,后来再次取得它,然后又释放它,而正确的做法是一直持有该锁。

(5).在想要用局部变量时,意外地通过使用全局变量共享数据。

(6).在不同的时间对共享数据使用两个不同的锁。

(7).由下列情况引起死锁:不恰当的锁定序列(加锁和解锁序列必须保持一致);锁定机制使用不当或错误选择;不释放锁或试图再次获取已经持有的锁。

一些常见的并发陷阱包括以下内容:

(1).缺乏公平:所有线程没有得到平等的机会来获得处理。

(2).饥饿:当一个线程霸占共享资源、阻止其它线程使用时发生。

(3).活锁:线程继续执行,但未能获得处理。

(4).假设线程将:以一个特定的顺序运行;不能同时运行;同时运行;在一个线程结束前获得处理。

(5).假设一个变量不需要锁定,因为开发人员认为只有一个线程写入它且所有其它线程都读取它。这还假定该变量上的操作是原子的。

(6).使用非线程安全库。如果一个库能保证由多个线程同时访问时不会产生数据竞争,那么认为它是线程安全的。

(7).依托测试,以找到数据竞争和死锁。

(8).内存分配和释放问题。当内存在一个线程中分配而在另一个线程中释放时,这些问题可能出现,不正确的同步可能会导致内存仍然被访问时被释放。

死锁:传统上,通过使冲突的竞争窗口互斥,使得一旦一个临界区开始执行时,没有额外的线程可以执行,直到前一个线程退出临界区为止,从而消除竞争条件。但是,同步原语的不正确使用可能会导致死锁(deadlock)。当两个或多个控制流以彼此都不可以继续执行的方式阻止对方时,就会发生死锁。特别是,对于一个并发执行流的循环,如果其中在循环中的每个流都已经获得了导致在循环中随后的流悬停的同步对象,则会发生死锁。死锁的一个明显的安全漏洞是拒绝访问。

int shared_data5 = 0;
std::mutex* locks5 = nullptr;
int thread_size5;
void thread_function5(int id)
{
	if (0) { // 产生死锁
		// 此代码将产生一个固定数量的线程,每个线程都修改一个值,然后读取它.虽然通常一个锁就足够了,但是每个
		// 线程(thread_size5)都用一个锁守卫共享数据值.每个线程都必须获得两个锁,然后才能再访问该值.如果
		// 一个线程首先获得锁0,第二个线程获得锁1,那么程序将会出现死锁
		if (id % 2)
			for (int i = 0; i < thread_size5; ++i)
				locks5[i].lock();
		else
			for (int i = thread_size5; i >= 0; --i)
				locks5[i].lock();

		shared_data5 = id;
		fprintf(stdout, "thread: %d, set shared value to: %d\n", id, shared_data5);

		if (id % 2)
			for (int i = thread_size5; i >= 0; --i)
				locks5[i].unlock();
		else
			for (int i = 0; i < thread_size5; ++i)
				locks5[i].unlock();
	}
	else { // 不会产生死锁
		// 每个线程都以同一顺序获取锁,可以消除潜在的死锁.下面的程序无论创建多少线程都不会出现死锁
		for (int i = 0; i < thread_size5; ++i)
			locks5[i].lock();

		shared_data5 = id;
		fprintf(stdout, "thread: %d, set shared value to: %d\n", id, shared_data5);

		for (int i = 0; i < thread_size5; ++i)
			locks5[i].unlock();
	}
}

void test_concurrency_deadlock()
{
	thread_size5 = 5;
	std::thread* threads = new std::thread[thread_size5];
	locks5 = new std::mutex[thread_size5];

	for (size_t i = 0; i < thread_size5; ++i)
		threads[i] = std::thread(thread_function5, i);

	for (size_t i = 0; i < thread_size5; ++i)
		threads[i].join();

	// test_concurrency_deadlock()继续之前,等待直到线程完成
	delete[] locks5;
	delete[] threads;
	fprintf(stdout, "Done\n");
}

像所有的数据竞争一样,死锁行为对环境的状态而不只是程序的输入敏感。特别是,死锁(和其它的数据竞争)可能对以下条件敏感:

(1).处理器速度。

(2).进程或线程调度算法的变动。

(3).在执行的时候,强加的不同内存限制。

(4).任何异步事件中断程序执行的能力。

(5).其它并发执行进程的状态。

过早释放锁:

std::mutex shared_lock6;
int shared_data6 = 0;
void thread_function6(int id)
{
	// 每个线程都把一个共享变量设置为它的线程编号,然后打印出共享变量的值.为了防止数据竞争,每个线程都
	// 锁定一个互斥量,以使变量被正确地设置
	if (0) { // 过早地释放锁
		// 当共享变量的每一个写操作都由互斥量所保护时,随后的读取是不受保护的
		shared_lock6.lock();
		shared_data6 = id;
		fprintf(stdout, "thread: %d, set shared value to: %d\n", id, shared_data6);
		shared_lock6.unlock();
		std::this_thread::sleep_for(std::chrono::milliseconds(id) * 100);
		fprintf(stdout, "thread: %d, has shared value to: %d\n", id, shared_data6);
	}
	else {
		// 读取和写入共享数据都必须受到保护,以确保每一个线程读取到它写入的相同的值.将临界区扩展为包括读取值,此代码就呈现为线程安全的
		// 需要注意的是,线程的顺序仍然可以有所不同,但每个线程都正确地打印出线程编号
		shared_lock6.lock();
		shared_data6 = id;
		fprintf(stdout, "thread: %d, set shared value to: %d\n", id, shared_data6);
		std::this_thread::sleep_for(std::chrono::milliseconds(id) * 100);
		fprintf(stdout, "thread: %d, has shared value to: %d\n", id, shared_data6);
		shared_lock6.unlock();
	}
}

void test_concurrency_prematurely_release_lock()
{
	const size_t thread_size = 10;
	std::thread threads[thread_size];

	for (size_t i = 0; i < thread_size; ++i)
		threads[i] = std::thread(thread_function6, i);

	for (size_t i = 0; i < thread_size; ++i)
		threads[i].join();

	// test_concurrency_prematurely_release_lock()继续之前,等待直到线程完成
	fprintf(stdout, "Done\n");
}

争用:当一个线程试图获取另一个线程持有的锁时,就会发生锁争用。有些锁争用是正常的,这表明,锁正在”工作”,以防止竞争条件。过多的锁争用会导致性能不佳。减少持有锁的时间量或通过降低每个锁保护的粒度或资源量,可以解决锁争用导致的性能差的问题。持有锁的时间越长,另一个线程尝试获取锁,并被迫等待的概率将越大。反之,减少持有锁的持续时间就减少了争用。例如,不会作用于共享资源的代码,不需要在临界区之内得到保护,并可以与其它线程并行运行。在一个临界区之内执行一个阻塞操作延伸了临界区的持续时间,从而增加了潜在的争用。在临界区之内的阻塞操作也可能导致死锁。在临界区之内执行阻塞操作几乎始终是一个严重的错误。

锁的粒度也可以影响争用。增加由一个单一锁保护的共享资源的数量,或扩大共享资源的范围(例如,锁定整个表以访问一个单元格),将使在同一时间多个线程尝试访问该资源的概率增大。在选择锁的数量时,增加锁的开销和减少锁争用之间有一个权衡。更细的粒度(每个保护少量的数据)需要更多的锁,使得锁本身的开销增加。额外的锁也会增加死锁的风险。锁一般是相当快的,但是,当然单个执行线程运行速度会比没有锁更慢。

ABA问题:在同步过程中,当一个位置被读取两次,并有相同的值供读取时,就发生ABA的问题。然而,第二个线程已在两次读取之间执行并修改了这个值,执行其它工作,然后把值再修改回来,从而愚弄第一个线程,让它以为第二个线程尚未执行。实现无锁数据结构时,经常会遇到ABA问题。如果将一个条目从列表中移除,并删除,然后分配一个新的条目,并把它添加到列表中,因为优化,新的对象通常会放置在被删除的对象的相同位置。因此,指向新条目的指针可能等于旧项目的指针,这可能会导致ABA问题。

自旋锁(spinlock):是一种类型的锁实现,其中线程在一个循环中反复尝试获得锁,直到它终于成功。一般而言,只有当等待获得锁的时间很短时,自旋锁才是有效的。在这种情况下,自旋锁避免了昂贵的上下文切换时间和在传统的锁中等待资源时,调度运行需要花费的时间。当获得锁的等待时间是明显长时,自旋锁在试图获取一个锁时就会浪费大量的CPU时间。一个常见的防止自旋锁浪费CPU周期的缓解措施是,在while循环中让该线程休眠或把控制让给其它线程。

以上代码段的完整code见:GitHub/Messy_Test

GitHubhttps://github.com/fengbingchun/Messy_Test

©️2020 CSDN 皮肤主题: 编程工作室 设计师: CSDN官方博客 返回首页
实付0元
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值