从程序员角度看,共享内存系统的任意处理器核都能访问任意内存区域。
如果不同的处理器核尝试更新内存区域上同一个位置的数据,会导致共享区域的内容无法预测,更新的代码段称为临界区(critical section)。
4.1 进程、线程和Pthreads
大体上,将线程看成是轻量级进程。进程是正在运行(或挂起)的程序的一个实例,除了可执行代码外,它还包括:
1.栈段;
2.堆段;
3.系统为进程分配的资源描述符,如文件描述符等;
4.安全信息,如进程允许访问的硬件和软件资源;
5.描述进程状态的信息,如进程是否准备允许或者正在等待某个资源,寄存器中的内容等。
在大多数系统中,在默认情况下,一个进程的内存块是私有的:其他进程无法直接访问,除非操作系统进行干涉。一个用户进程是绝对不允许访问其他用户拥有的内存。
更通用的术语是线程,它来自于“控制线程”的概念,控制线程是程序中一个语句序列,建立在单个进程中使用术语控制流。在共享内存的程序中,一个进程可以有多个控制线程。
4.2 “Hello World”程序
4.2.1 执行
需要链接Pthreads线程库:
$gcc -g -Wall -o pth_hello pth_hello.c -lpthread
-lpthread告诉编译器,我们需要链接Pthreads线程库。要运行编译好的程序,只要键入./pth_hello <number of threads>。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
// 函数原型
void* hello_world(void* rank);
// 全局变量
const int thread_count = 16;
int main()
{
pthread_t* thread_handle = new pthread_t[thread_count];
for (int thread = 0; thread < thread_count; thread++)
{
// pthread_create传递给函数的参数只能一个指针,
// 若函数所需参数不止一个,则可以将参数封装在一个struct内即可。
pthread_create(&thread_handle[thread], NULL, hello_world, (void*)thread);
}
printf("Hello from the main thread\n");
for (int thread = 0; thread < thread_count; thread++)
{
pthread_join(thread_handle[thread], NULL);
}
delete[] thread_handle;
return 0;
}
// 函数定义
void* hello_world(void* rank)
{
int my_rank = int(rank);
printf("Hello from thread %d of %d\n", my_rank, thread_count);
// 由于返回值是void*指针,所以需要返回一个指针类型。
return NULL;
}
4.2.2 准备工作
pthread.h是线程库pthread的头文件,用来声明Pthreads的函数、常量和类型等。
全局变量被所有线程所共享,而在函数中声明的局部变量则(通常)由执行该函数的线程所私有。如果多个线程都要运行同一个函数,则每个线程都拥有自己的私有局部变量的函数参数的副本。
根据经验法则,应该限制使用全局变量,除了确实需要用到的情况外,比如线程之间共享变量。
4.2.3 启动线程
需要在程序中添加相应的代码来显示地启动线程,并构造能够存储线程信息的数据结构。
pthread_t数据结构用来存储线程的专有信息,它由pthread.h声明的。
pthread_t对象是不透明的对象。对象中存储的数据都是系统绑定的,用户级代码无法直接访问到里面的数据。
Pthreads标准保证Pthread_t对象必须存有足够多的信息,足以让pthread_t对象对它所从属的线程进行唯一标识。
int pthread_create(
pthread_t* thread_p,
const pthread_attr_t* attr_p,
void* (*start_routine)(void),
void* arg_p);
第一个参数是一个指针,指向对应的pthread_t对象。注意,pthread_t对象不是由pthread_create函数分配的,必须在调用pthread_create函数前就为pthread_t对象分配内存空间。第二个参数不用,所以只是在调用函数时把NULL传递给参数。第三个参数表示该线程将要运行的函数;最后一个参数也是指针,指向传给函数start_routine的参数。
由pthread_create生成并运行的函数应该有一个类似于下面函数的原型:
void* thread_function(void* args_p);
因为void*可以转换为任意指针类型,所以args_p可以指向一个列表,该列表包含一个或多个thread_function函数需要的数值。类似地,thread_function返回的值也可以是一个包含一个或多个值的列表。
pthread_create创建线程时没有要求必须传递线程号,也没有要求要分配一个线程号给一个线程。
并非由于技术上的原因而规定每个线程都要运行相同的函数。一个线程运行hello函数时,另一个线程也可以运行goodbye函数。即每个线程都执行同样的线程函数,但可以在线程内用条件转移来获取不同线程有不同功能的效果。
4.2.4 运行线程
运行main函数的线程一般称为主线程。
在pthread库中,程序员不直接控制在那个核上运行。在pthread_create函数中,没有参数用于指定在那个核上运行线程。线程的调度是由操作系统控制的。在负载很重的系统上,所有线程可能都运行在同一个核上。事实上,如果线程个数大于核的个数,就会出现多个线程运行在一个核上。当然,如果某个核处于空闲状态,操作系统就会将一个新线程分配给该核。
4.2.5 停止线程
调用一次pthread_join()将等待pthread_t对象所关联的那个线程结束。
int pthread_join(pthread_t thread, void** ret_val_p);
第二个参数可以接受由任意pthread_t对象所关联的那个线程产生的返回值。
假设主线程在图中是一条直线,调用pthread_create后就创建了主函数的一条分支或派生,多次调用pthread_create就会出现多条分支或派生。当pthread_create创建的线程结束时,这些分支最后又合并(join)到主线程的直线中。
4.2.6 错误检查
最好一开始先检查命令行参数;允许的话,还可以检查输入的实际线程数目是否合理。
另外,检查由Pthreads函数返回的错误代码也是一个好办法。
4.2.7 启动线程的其他方法
线程的启动也是有开销的。启动一个线程花费的时间远远比进行一次浮点运算的时间多,所以,按需启动线程的方法也许不是使应用程序最优化的理想方法。一种考虑是,主线程可以在程序一开始时就启动所有的线程,当一个线程没有工作可做时,并不结束该线程,而是让该线程处于等待状态,直到再次分配到要执行的任务。
4.3 矩阵-向量乘法
Ax=y。
最低限度下,要共享x。如果把A和x都设为共享,看上去好像违反了“只有需要共享的数据才能成为全局变量”的法则。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
// 函数原型
void* matMulVec(void* rank);
const int thread_count = 4, row_count = 16, column_count = 4;
int A[row_count][column_count], x[column_count], y[row_count];
int main()
{
for (int i = 0; i < row_count; i++)
for (int j = 0; j < column_count; j++)
{
A[i][j] = i + j;
x[j] = column_count - j;
}
pthread_t* thread_handle = new pthread_t[thread_count];
for (int thread = 0; thread < thread_count; thread++)
{
// pthread_create传递给函数的参数只能一个指针,
// 若函数所需参数不止一个,则可以将参数封装在一个struct内即可。
pthread_create(&thread_handle[thread], NULL, matMulVec, (void*)thread);
}
for (int thread = 0; thread < thread_count; thread++)
{
pthread_join(thread_handle[thread], NULL);
}
for (int i = 0; i < row_count; i++)
printf("%d\n", y[i]);
delete[] thread_handle;
return 0;
}
// 函数定义
void* matMulVec(void* rank)
{
int my_rank = int(rank);
int my_first_row = my_rank * row_count / thread_count;
int my_last_row = (my_rank + 1) * row_count / thread_count;
for (int i = my_first_row; i < my_last_row; i++)
{
y[i] = 0;
for (int j = 0; j < column_count; j++)
{
y[i] += A[i][j] * x[j];
}
}
// 由于返回值是void*指针,所以需要返回一个指针类型。
return NULL;
}
4.4 临界区
在矩阵-向量乘法案例中,在程序初始化后,线程只读取了除y之外的所有变量。即在主函数创建线程后,除了y之外,没有任何共享变量被改写。即使是y,也是每个线程各自改变属于自己运算的那一部分,没有两个或以上线程共同处理同一部分y的数据。
如果多个线程需要更新同一内存单元的数据,该区域被称为临界区域。
例如计算pi值。
if (my_arg->my_rank % 2 == 0)
factor = 1.0;
else
factor = -1.0;
for (int i = my_first_i; i < my_last_i; i++, factor = -factor)
{
while (flag != my_arg->my_rank);
if (i < elem_size)
sum += factor / (2 * i + 1);
else;
flag = (flag + 1) % thread_count;
}
其中sum被多个线程同时更改,引发错误。当多个线程尝试更新同一共享变量时,结果可能是无法预测的。如果至少其中一个访问是更新操作,那么这些访问就可能导致某种错误,我们称之为竞争条件(race condition)。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <windows.h>
const int thread_count = 4, elem_size = 1e6;
static int flag = 0;
static double sum = 0.0;
pthread_mutex_t mutex;
struct Para
{
int my_rank;
};
// 函数原型
void* sumBusyWaiting(void* arg);
void* sumLocalBusyWaiting(void* arg);
void* sumMutex(void* arg);
//
int main()
{
pthread_t* thread_handle = new pthread_t[thread_count];
Para* arg = new Para[thread_count];
// pthread_mutex_init(&mutex, NULL);
for (int thread = 0; thread < thread_count; thread++)
{
// pthread_create传递给函数的参数只能一个指针,
// 若函数所需参数不止一个,则可以将参数封装在一个struct内即可,
// 传递一个结构的指针速度慢于CPU处理速度,从而造成传入给每个线程的实际参数并非预想.
// 所以给每个线程的参数都分配一块内存new开辟的参数内存区域arg[thread]。
arg[thread].my_rank = thread;
pthread_create(&thread_handle[thread], NULL, sumBusyWaiting, (void*)(&arg[thread]));
}
for (int thread = 0; thread < thread_count; thread++)
pthread_join(thread_handle[thread], NULL);
printf("pi值: %f\n", 4 * sum);
pthread_mutex_destroy(&mutex);
delete[] thread_handle;
delete[] arg;
return 0;
}
// 函数定义
4.5 忙等待
一个不涉及新概念的简单方法就是使用标志变量,设标记flag是一个共享的int型变量,主线程将其初始化为0。关键点是while循环语句。在忙等待中,线程不停地测试某个条件,但实际上,直到某个条件满足之前,这些测试都是徒劳的。
需要注意的是,忙等待这种方法有效的前提是,“严格按照书写顺序来执行代码”。如果有编译器优化,那么编译器进行的某些代码优化的工作会影响到忙等待的正确执行。因为编译器可能为了充分利用寄存器,可以将某些语句的顺序交换。这样可能会使忙等待while循环语句代码失效。
从运算结果来看,导致运行慢的原因不在于线程开销,而是线程不停地在等待和运行之间切换,显然是等待。
void* sumBusyWaiting(void* arg)
{
Para* my_arg = static_cast<Para*>(arg);
int local_size = (elem_size + thread_count - 1)/ thread_count;
int my_first_i = my_arg->my_rank * local_size;
int my_last_i = (my_arg->my_rank + 1) * local_size;
double factor = 0;
if (my_arg->my_rank % 2 == 0)
factor = 1.0;
else
factor = -1.0;
for (int i = my_first_i; i < my_last_i; i++, factor = -factor)
{
while (flag != my_arg->my_rank);
if (i < elem_size)
sum += factor / (2 * i + 1);
else;
flag = (flag + 1) % thread_count;
}
// 由于返回值是void*指针,所以需要返回一个指针类型。
return NULL;
}
循环后对临界值求和:
void* sumLocalBusyWaiting(void* arg)
{
Para* my_arg = static_cast<struct Para*>(arg);
int local_size = (elem_size + thread_count - 1) / thread_count;
int my_first_i = my_arg->my_rank * local_size;
int my_last_i = (my_arg->my_rank + 1) * local_size;
double factor = 0;
double my_sum = 0.0;
if (my_arg->my_rank % 2 == 0)
factor = 1.0;
else
factor = -1.0;
// 循环后用临界区归约求和
for (int i = my_first_i; i < my_last_i; i++, factor = -factor)
{
if (i < elem_size)
my_sum += factor / (2 * i + 1);
else;
}
while (flag != my_arg->my_rank);
sum += my_sum;
flag = (flag + 1) % thread_count;
// 由于返回值是void*指针,所以需要返回一个指针类型。
return NULL;
}
无论如何限制访问临界区,都必须串行执行其中的代码。如果可能,我们应该最小化执行临界区的次数。能够大幅度提高性能的一个方法是,给每个线程配置私有变量来存储各自的部分和,然后用for循环一次将所有部分和加在一起。
4.6 互斥量
因为忙等待的线程仍然在持续使用CPU,所以忙等待不是限制临界区访问的最理想方法。这里有两个更好的方法:互斥量和信号量。
互斥量是互斥锁的简称,它是一个特殊类型的变量,通过某些特殊类型的函数,互斥量可以用来限制每次只有一个线程能进入临界区。
pthread为互斥量提供了一个特殊类型pthread_mutex_t。在使用pthread_mutex_t之前,需要对其初始化。其函数如下:
int pthread_mutex_init(pthread_mutex_* mutex_p, const pthread_mutexattr_t attr_p);
此处,我们不使用第二个参数。
当使用完pthread_mutex_t后,应该调用pthread_mutex_destory函数销毁它。
int pthread_mutex_destory(pthread_mutex_* mutex_p);
要获取临界区的访问权,线程需要调用:
int pthread_mutex_lock(pthread_mutex_t* mutex_p);
当线程退出临界区后,它应该调用:
int pthread_mutex_unlock(pthread_mutex_t* mutex_p);
通过声明一个全局的互斥量,可以在求全局和的程序中使用互斥量代替忙等待。主线程对互斥量进行初始化。在线程进入临界区前调用pthread_mutex_lock,在执行完临界区的所有操作后在调用pthread_mutex_unlock。
第一个调用pthread_mutex_lock的线程会给临界区锁门,其他调用pthread_mutex_lock都会阻塞并等待,直到第一个调用的线程退出临界区,释放锁。
注意,在使用互斥量的多线程程序中,多个线程进入临界区的顺序是随机的,第一个调用pthread_mutex_lock的线程率先进入临界区,接下去的线程顺序由操作系统负责分配。
注意,使用忙等待的线程数超过核数后,性能会下降。
void* sumMutex(void* arg)
{
Para* my_arg = static_cast<struct Para*>(arg);
int local_size = (elem_size + thread_count - 1) / thread_count;
int my_first_i = my_arg->my_rank * local_size;
int my_last_i = (my_arg->my_rank + 1) * local_size;
double factor = 0;
double my_sum = 0.0;
if (my_arg->my_rank % 2 == 0)
factor = 1.0;
else
factor = -1.0;
// 循环后用临界区归约求和
for (int i = my_first_i; i < my_last_i; i++, factor = -factor)
{
if (i < elem_size)
my_sum += factor / (2 * i + 1);
else;
}
pthread_mutex_lock(&mutex);
sum += my_sum;
pthread_mutex_unlock(&mutex);
// 由于返回值是void*指针,所以需要返回一个指针类型。
return NULL;
}
4.7 生产者-消费者同步和信号量
尽管忙等待总是浪费CPU的资源,但它是我们至今所知的,能事先确定线程执行临界区代码顺序的最适合方法(需禁止编译器优化)。
如果采用互斥量后,那么线程进入临界区以及此后的顺序由系统随机选取。因为加法是可交换的,所以pi计算结果不受线程执行顺序的影响。
比如要控制线程进入临界区的顺序。假设每个线程生成一个N*N矩阵,按照线程号的顺序依次将各个线程的矩阵相乘。但矩阵相乘是不可交换的,使用互斥量会出现问题的。
例如每个线程给另一个线程发送消息的例子。使用互斥量的问题在于,我们无法知道什么时候线程执行到调用pthread_mutex_lock。不过也有办法处理该问题,但逻辑比较复杂。
pthreads还提供另一个控制访问临界区的方法:信号量(semaphore)。
信号量可以认为是一种特殊类型的unsigned int无符号整型变量,可以赋值为0,1,2....。大多数情况下,只给它们赋值为0和1。这种只有0和1值的信号量称为2元信号量。粗略地说,0对应了上了锁的互斥量,1对应于未上锁的互斥量。要把一个二元信号用作互斥量时,需要先把信号量的值初始化为1,即开锁状态。
在要保护的临界区前调用sem_wait,线程执行到sem_wait函数时,如果信号量为0,线程就会被阻塞。如果信号量是非0值,就减1后进入临界区。执行完临界区内的操作后,在调用sem_post对信号量加1,使得在sem_wait中阻塞的其他线程能够继续运行。
信号量与互斥量最大的区别在于信号量是没有个体拥有权,主线程将所有的信号量初始化为0,即加锁,其他线程都能对任何信号量调用sem_wait和sem_post函数。互斥量上锁解锁都只能由同一个线程完成,而信号量则任意。
信号量不是Pthreads线程库的一部分,所以需要在使用信号量的程序开头加入头文件。
消息发送的例子不涉及到临界区。问题已经不再是一段代码一次只能被一个线程执行,而变成了my_rank在线程source发出消息前一直被阻塞。这种一个线程需要等待另一个线程执行某种操作的同步方式,有时被称为生产者-消费者同步模型。
#include <sstream>
#include <iostream>
#include <stdlib.h>
#include <pthread.h>
#include <semaphore.h>
#include <string>
const int thread_count = 20, elem_size = 1e2;
static int flag = 0;
static double sum = 0.0;
static std::string messages[thread_count];
sem_t semaphores[thread_count];
struct Para
{
int my_rank;
};
// 函数原型
void* sendMsg(void* arg);
//
int main()
{
pthread_t* thread_handle = new pthread_t[thread_count];
Para* arg = new Para[thread_count];
for (int thread = 0; thread < thread_count; thread++)
{
sem_init(&semaphores[thread], 0, 0);
arg[thread].my_rank = thread;
pthread_create(&thread_handle[thread], NULL, sendMsg, (void*)(&arg[thread]));
}
for (int thread = 0; thread < thread_count; thread++)
{
pthread_join(thread_handle[thread], NULL);
sem_destroy(&semaphores[thread]);
}
delete[] arg;
delete[] thread_handle;
return 0;
}
void* sendMsg(void* arg)
{
int my_rank = (static_cast<struct Para*>(arg))->my_rank;
int dest = (my_rank + 1) % thread_count;
int source = (my_rank - 1 + thread_count) % thread_count;
using std::string;
using std::ostringstream;
ostringstream ostr;
ostr << "Hello to " << dest << " from " << my_rank;
string my_msg = ostr.str();
messages[my_rank] = my_msg;
sem_post(&semaphores[dest]);
sem_wait(&semaphores[my_rank]);
// std::cout << std::unitbuf;
// std::cout << "Thread " << my_rank << " > " << messages[my_rank] << std::endl;
printf("Thread %d > %s\n", my_rank, messages[my_rank].c_str());
return NULL;
}
4.8 路障和条件变量
共享内存编程的另一个问题:通过保证所有线程在程序中处于同一个位置来同步线程。这个同步点又称为路障(barrier),只有所有线程都抵达此路障,线程才能继续运行下去,否则会阻塞在路障处。
4.8.1 忙等待和互斥量
用忙等待和互斥量来实现路障比较直观。我们使用一个由互斥量保护的共享计数器。当计数器的值表明每个线程都进入临界区,所有线程就可以离开忙等待的状态。
#include <pthread.h>
#include <semaphore.h>
const int thread_count = 20;
int count = 0;
pthread_mutex_t barrier_mutex;
void* barrierBusyAndMutex(void* arg)
{
int my_rank = (static_cast<struct Para*>(arg))->my_rank;
// barrier constructed by busy-wait and mutex
pthread_mutex_lock(&barrier_mutex);
count++;
pthread_mutex_unlock(&barrier_mutex);
while (count < thread_count);
...
return NULL;
}
用忙等待和互斥量实现路障会有两个问题:第一个问题是,线程处于忙等待循环时浪费了很多CPU周期,并且当程序中的线程数多于核数时,程序的性能会直线下降。第二个问题是,在于共享计数变量。当第一个路障完成后,计数变量的值是线程数目。除非重置计数变量。否则计数变量在第二个路障失效。
若重置计数变量,则选择重置时间。当最后一个进入循环并重置计数变量,那么其他一直处于忙等待的线程将永远看不到count=thread_count值,则除最后一个线程的其他线程会永远处于忙等待中;如果是在经过路障后重置计数变量。另一些线程可能会在重置前已经进入第二个路障,由这些线程引起的在第二个路障处计数增加值就会因第一个路障后的计数重置而丢失,这会导致所有线程都在第二个路障困住。如果用这种方式实现路障,则有多少路障需要有多少个计数变量。
4.8.2 信号量
采用两个信号量,count_sem, 一个用于保护计数器;barrier_sem,用于阻塞已经进入路障的线程。count_sem被初始化为1。此类方法解决了忙等待实现路障的两个问题。消耗CPU周期与count重用。但引出一个新的问题,当最后一个线程进入sem-post(barrier_sem)加完后,然后进入下一个路障点处,若在第一个路障处的被挂起的某个线程没有消耗掉最后一个线程加上的某一个sem_post的量,那么最后一个线程在第二个路障处会直接通过路障。而不会阻塞在第二个路障处。所以导致了对barrier_sem信号量的的竞争情形。
#include <pthread.h>
#include <semaphore.h>
const int thread_count = 20;
int count = 0;
sem_t count_sem; // 初始化为1
sem_t barrier_sem;
void* barrierSemaphore(void* arg)
{
int my_rank = (static_cast<struct Para*>(arg))->my_rank;
// barrier constructed by semaphore
sem_wait(&count_sem);
if (count == thread_count - 1)
{
count = 0;
sem_post(&count_sem);
for (int j = 0; j < thread_count; ++j)
sem_post(&barrier_sem);
}
else
{
++count;
sem_post(&count_sem);
sem_wait(&barrier_sem);
}
...
return NULL;
}
4.8.3 条件变量
在Pthreads中实现路障的更好方法是采用条件变量。条件变量是一个数据对象,允许线程在某个特定条件或事件发生前都处于挂起状态。当事件或条件发生时,另一个线程可以通过信号来唤醒挂起的线程。一个条件变量总是和一个互斥量相关联。
#include <pthread.h>
#include <semaphore.h>
const int thread_count = 20;
int count = 0;
pthread_cond_t cond_var;
pthread_mutex_t mutex;
void* barrier_condition(void* arg)
{
int my_rank = (static_cast<struct Para*>(arg))->my_rank;
// barrier constructed by condition
pthread_mutex_lock(&mutex);
count++;
if (count == thread_count)
{
count = 0;
pthread_cond_broadcast(&cond_var);
}
else
{
while (pthread_cond_wait(&cond_var, &mutex) != 0);
}
pthread_mutex_unlock(&mutex);
return NULL;
}
pthread_cond_signal的作用是唤醒一个线程。pthread_cond_wait的作用是解锁所有被阻塞的线程。pthread_con_wait相当于顺序执行了以下代码:
pthread_mutex_unlock(&mutex_p);
wait_on_signal(&cond_var_p);
pthread_mutex_lock(mutex_p);
若将pthread_cond_wait放在while循环中,如果被阻塞的线程被其他事件唤醒,而不是被pthread_cond_broadcast或pthread_cond_signal唤醒,那么能检查到pthread_cond_wait的返回值不为0,则被解除阻塞的线程还会继续执行该函数。
注意,为了路障的正确性,必须调用pthread_cond_wait来解锁。如果没有用这个函数对互斥量进行解锁,那么只有第一个线程能进入路障,所有其余的线程将被阻塞在pthread_mutex_lock(&mutex)上。而第一个线程则会在pthread_cond_wait的调用上。从而程序将会被挂起。
与互斥量和信号量一样,条件变量也应该初始化和销毁。
4.9 读写锁
4.9.1 链表函数
// pthread_linklist.h
#ifndef _PTHREAD_LINKLIST_H_
#define _PTHREAD_LINKLIST_H_
#include <pthread.h>
struct list_node_s
{
int data;
list_node_s* next;
pthread_mutex_t mutex;
};
int member(int, struct list_node_s* );
int insert(int, struct list_node_s* );
int dlt(int, struct list_node_s*);
pthread_rwlock_t rwlock;
#endif // !1
// pthread_linklist.cpp
#include "pthread_linklist.h"
int member(int value, struct list_node_s* head_p)
{
struct list_node_s* curr_p = head_p;
while (curr_p != nullptr && curr_p->data < value)
curr_p = curr_p->next;
if (curr_p == nullptr || curr_p->data > value)
return 0;
else
return 1;
}
// 插入步骤:
// 1 判断当前节点是否为空?
// 2 while循环,不是尾结点 and 未到目标节点
// 3 是否到尾结点 or 是否搜索到目标节点
// 3.1 目标节点是否为头结点
int insert(int value, struct list_node_s* head_p)
{
struct list_node_s* curr_p = head_p;
struct list_node_s* pred_p = nullptr;
struct list_node_s* temp_p;
// i第一次是检测链表不为空,后面是检测是否到尾结点, 以及 搜索到目标节点之前
while (curr_p != nullptr && curr_p->data < value)
{
pred_p = curr_p;
curr_p = curr_p->next;
}
// 达到尾节点之前 或 搜索到目标节点
if (curr_p == nullptr || curr_p->data > value)
{
temp_p = new list_node_s;
temp_p->data = value;
temp_p->next = curr_p;
if (pred_p = nullptr) // 目标节点是否为头结点
head_p = temp_p;
else
pred_p->next = temp_p;
return 1;
}
else
return 0;
}
int dlt(int value, struct list_node_s* head_p)
{
struct list_node_s* curr_p = head_p;
struct list_node_s* pred_p = nullptr;
while (curr_p != nullptr && curr_p->data < value)
{
pred_p = curr_p;
curr_p = curr_p->next;
}
if (curr_p != nullptr && curr_p->data == value)
{
if (pred_p == nullptr)
head_p = curr_p->next;
else
pred_p->next = curr_p->next;
delete curr_p;
return 1;
}
else
return 0;
}
多个线程能够没有冲突的同时读取一个内存单元。但不能同时读写同一个内存单元。当多个线程同时访问链表且至少有一个线程正在执行写操作,这是不安全的操作。针对上述问题,解决方法有三,第一,将整个函数用互斥量来保护。带来的问题是,必须串行访问链表。
pthread_mutex_lock(&list_mutex);
member(value);
pthread_mutex_unlock(&list_mutex);
第二个方法是细粒度的锁,对链表上的单节点上锁。而不是对整个链表上锁。带来的问题是,内存增加,给单一节点上锁释放锁,增加了函数调用。
int memberNodeMutex(int value, struct list_node_s* head_p)
{
struct list_node_s* temp_p;
pthread_mutex_lock(&head_p->mutex);
temp_p = head_p;
while (temp_p != nullptr && temp_p->data < value)
{
if (temp_p->next != nullptr) // 给下一个节点上锁。
pthread_mutex_lock(&temp_p->next->mutex);
if (temp_p == head_p) // 给头结点释放锁,因为进入list之前,给头结点上了锁。
pthread_mutex_unlock(&head_p->mutex);
pthread_mutex_unlock(&temp_p->mutex); // 给当前节点释放锁。
temp_p = temp_p->next;
}
if (temp_p == nullptr || temp_p->data > value)
{
if (temp_p == head_p) // 若头结点是目标节点,则上述while循环没进行,头结点锁未释放。
pthread_mutex_unlock(&head_p->mutex);
if (temp_p != nullptr) // 若上述while循环时发现temp_p->data > value时,while循环退出,但temp_p在上一次迭代依旧更新且被上锁了。
pthread_mutex_unlock(&temp_p->mutex);
return 0;
}
else
{
if (temp_p == head_p)
pthread_mutex_unlock(&head_p->mutex);
pthread_mutex_unlock(&temp_p->mutex);
return 1;
}
}
4.9.3 读写锁
前两种方法都不让正在执行member的函数的线程还可以同时访问链表的其他任意节点。第一种方法是任意时刻只允许一个线程访问整个链表,第二个方法是任意时刻只允许一个线程访问任一给定节点。第三个方法是读写锁。除了提供两个锁函数外,读写锁与互斥量基本差不多。第一个为读操作读写锁进行加锁,第二个为写操作进行读写锁加锁。多个线程能通过调用读锁函数而同时获得锁,但只有一个线程能通过写锁函数获得锁。因此,如果任何线程拥有了读锁,则请求写锁的线程将阻塞在写锁函数的调用上。而且,如果任何线程拥有了写锁,则任何想获得读或写锁的线程将阻塞在它们对应的锁函数上。
// 读锁
pthread_rdlock_rdlock(&rwlock);
member(value);
pthread_rwlock_unlock(&rwlock);
// 写锁
pthread_rwlock_wrlock(&rwlock);
insert(value);
pthread_rwlock_unlock(&rwlock);
读写锁也应该初始化和销毁。pthread_rwlock_int()和pthread_rwlock_destory()。
在多线程情况下,每个节点的一个互斥量的实现仍然维持其低效性。过多的加锁开锁导致开销太大。
读写锁和给整个链表加锁的实现相对性能差距不是很大,但读写锁还是优于给整个链表加锁。
4.9.5 实现读写锁
典型的读写锁实现定义了一个数据结构,该数据结构使用了两个条件变量(一个对应读者,一个对应写者)和一个互斥量。该数据结构还包含了一些成员:
1.多少读者拥有锁,即多少线程在同时读;
2.多少读者正在等待锁;
3.是否有一个写者拥有锁;
4.多少写者正在等待获取锁;
互斥量用于保护读写锁的数据结构。无论何时一个线程调用其中的任意一个函数(读锁、写锁、解锁),它必须首先锁互斥量,并且无论何时一个线程完成了这些函数调用的一个,它必须解锁互斥量。在获取互斥量后,线程检查合适的数据成员来决定接下来干什么。
4.10 缓存、缓存一致性和伪共享
处理器的执行速度比访问主存中数据的速度快得多。芯片设计人员已经为处理器增加相对快速的内存,这个更快的内存就是缓存(cache memory)。
缓存的设计考虑时间空间局部性原理。如果一个处理器在时间t访问内存位置x,那么很可能它在一个接近t的时间访问接近x的位置。如果一个处理器需要访问主存位置x,那么就不只是将x的内容传入传出主存,而是将一块包含x的内存块传入传出主存。我们将这样一块内存称为缓存行或缓存块。
当2个线程对内存中的变量x都进行不同操作时,则x至少有三个副本,内存中,两个线程的缓存,当2个线程都对x进行更新后,x的值会是多少? 这就是缓存一致性问题。
缓存一致性问题可能会对共享内存系统的性能造成巨大影响。
当一个核试图更新一个不在缓存中的变量时,就会发生写缺失(write-miss),处理器必须访问主存。
缓存一致性是“行级”的。也就是说,每次缓存行中的任何一个值被改写了,如果该行也存在另一个处理器的缓存中,不只是被写的那个值,在那个处理器上的整个缓存行都会无效。
假设2个拥有各自缓存的线程访问属于同一缓存行的不同变量。再进一步假设至少有一个线程更新了它的变量,那么即使没有线程写另一个线程正在使用的变量,缓存控制器仍然会使整个缓存行无效并强制线程从内存获取变量的值。线程并不共享任何东西(除了一个缓存行),但线程对内存访问的行为好像它们正在共享一个变量,因此将这种现象命名为伪共享。两个可能的解决方法是,一是用假的元素填充,二是使用各自线程的私有存储器。
4.11 线程安全性
如果一个代码块能够被多个线程同时执行,那么它是线程安全的。
4.12 小结
在Pthreads程序中,所有的线程都能访问全局变量,但是局部变量对于运行程序的线程来说是私有的。
当多个线程同时执行时,多个线程执行语句的顺序通常是非确定的。
当多个线程对某个共享变量进行访问时,且至少存在一个线程的访问是写操作,则此时的访问可能会造成错误,导致结果的不确定性,我们称这种现象为竞争条件(race condition)。
写共享内存最重要的任务之一就是识别和更正竞争条件。临界区(critical section)是一个代码块,在这个代码块中,任意时刻只有一个线程能够更新共享资源,因此临界资源的代码应为串行执行。因此,在设计程序时,应尽可能少用临界区,并且使用的临界区尽可能短。
有三种方法避免临界区访问的基本方法:忙等待,互斥量和信号量。
忙等待(busy-waiting)可以用一个标志量和一个空循环来实现。但它十分浪费CPU周期。
互斥量(mutex)可以保证对临界区的互斥访问。
信号量(semaphore)是一个有两个操作的无符号整形变量。信号量比互斥量功能更强,因为信号量没有归属权。所以信号量能很容易实现生产者-消费者同步问题。
一个路障(barrier)是程序中的一个节点,线程必须阻塞直到所有的线程都到达了这个结点。比较简单的实现方法是使用一个条件变量和一个互斥量。
Pthreads读写锁。当多个线程同时安全地读一个数据结构,可以使用读写锁。
现代微处理器结构使用缓存来减少内存的访问时间。典型的处理器结构有特殊的硬件来保证不同芯片上的缓存是一致的(coherent)。因为缓存一致的基本单位是一个缓存行(cache line)。
两个线程可能会访问不同的内存单元,但两个单元属于同一缓存行时,缓存一致性硬件会看成这两个线程正在访问同一单元。若至少有一个线程尝试更新缓存行的某一个单元,则其他线程所访问的该缓存行失效,必须去访问主存。这称为伪共享(false sharing)。
当多个线程同时运行程序,而不会引发错误时,则称该程序时线程安全的。