OpenMP并行程序设计——设计并行的数据结构

用OpenMP设计并行数据结构

并行程序设计

假期突然延长,为了不荒废人生,决定趁这两天补一下课,把之前没有修过的并行与分布式计算补习一下。
这门课主要教了MPI, Pthread, OpenMP和CUDA,内容围绕着并行计算和高性能计算展开,比较繁杂,知识点很琐碎,但实际上很深入很难的东西倒是没有。我是奔着学习技术来的,那自然是适当忽略理论部分,多写代码。
MPI是分布式内存设计,Pthread和OpenMP都是共享内存设计, Pthread比起 OpenMP更底层,设计起来自由度高但也更困难,所以我选择只学MPI和OpenMP。两者都花了两天左右的时间学习,过程中一边看书一边敲代码,感觉学习效率比开学时间高了不少(
这里我就用两篇博客来介绍两个简单的项目,简单讲一下一些OpenMP的使用方法和并行设计的理念。

OpenMP

Open Multi-Processing的缩写,是一个应用程序接口(API)。它是一个可以被编译器链接的库,它的并行是编译器决定的,所以使用起来很方便。它比起MPI的优点很明显,在编写程序时,可以动态的产生多线程,使用完了就可以合并回主线程,灵活度高;在多线程运行时还能共同使用公有变量,操作方便。像MPI这种从头并行到尾的,就需要我们更费力地思考要添加怎样的分支语句,要怎样分发数据才能抑制内存的浪费。还有一点,就是它在Windows的IDE上使用更加方便,对初学者更友好。
最常用的语句有

pragma omp parallel num_threads(X) //线程并行
pragma omp parallel for num_threads(X) //for循环并行
pragma omp critical //声明临界区,用于保护变量

并发式队列

这里用一个简单的例子介绍一下并行的数据结构设计思想。
考虑这样一个问题:我们有多个文件,每个文件中有特殊的数据要交给一种处理程序去处理。那么在多核处理器上,如何编写程序高效地解决问题呢?
首先考虑单处理器的情形,我们处理文件的方式自然是把文件的信息从头到尾进行读取,然后一段一段地交给处理程序进行处理,如果处理程序忙不过来,那么就需要队列让这些数据在此等待。
但是在多核处理器上,就有剩出来的核处于空闲状态。我们希望让这些多出来的核也去执行读文件和处理数据的工作。那我们的问题就来了,在多核并发地读取文件的时候,我们该如何正确的设计队列对数据排序呢?
我们回忆一下队列的结构,我们对队列的操作主要就是出队pop和入队push两种而已。数据域一般保存一个队首,一个队尾用来操作。于是我们发现,push和pop是可以并行的!(除了队列长度为1或为0的状态),这样我们完全可以用不同的线程,一边入队,一边出队。
再进一步思考,push入队的操作时间必然远小于线程读文件的时间。也就是线程在读文件时,完全可以让其他已经读好一段数据的线程去执行push,只要线程足够多,单位时间push的量就能足够大。所以,我们可以用多线程去并行读取文件,而串行执行push操作。
再再进一步思考,即使我们push的足够快,但读完文件后如果处理程序没能及时处理完队列内的数据,我们的任务还是没结束,我们还是要等待处理程序把队列处理干净。前面说过读文件和push可以并行,那么处理数据和pop一样可以。所以我们可以设计多线程去并行处理数据,串行pop操作。
至此我们的任务已经很明确了

  1. 设计队列允许push和pop同时进行
  2. 正确处理多个线程同时请求pop或push时的操作

push和pop同时进行只需要考虑一种特殊情况,即队列为空和队列长度为1时。队列为空时,pop不能进行;队列长为1时,push和pop互斥。
还有一点,一般队列需要保存队列长度以便使用。然而push和pop的并行会对length造成同时操作,进而让length变得不可预期。因此有必要用enqueue和dequeue两个变量来表示length,以避免这一冲突。
同时请求pop(push)时,根据队列的实现方式有不同的处理方案。如果是循环数组实现,则可以通过特殊指定下标来实现同时pop(push),如果是链表实现,则多个操作必须串行执行,它们是互斥的。
这里我们进一步明确问题: 有N个文件,内部的数据形式如下

destination    //目标队列
aaa bbbb ccccc dddddd  // 一些由空格分割的单词
destination    //目标队列
aaa bbbb ccccc dddddd  // 一些由空格分割的单词
...

包含这样的多组数据,每组的含义是:把这行单词字符串发送给destination号处理程序的队列。现在设计N个线程,一对一并行地读这些文件,并在读的过程中把字符串发送给destination号队列。此外,再设计M个线程,每个线程对应一个队列,对队列中的字符串进行分割处理,在处理时不断打印单词到stdout中。
很明显,我们可以用线程号指定每个线程是应该读文件还是处理字符串,还可以用线程号指定每个线程要处理的是哪个文件、哪个队列。文件和队列都可以用数组存储,用下标访问。其中队列和文件都应该被设计为共享变量;当队列长度为1时,push和pop是critical的;当队列被多个线程push时,应该设置线程锁,以避免多个线程同时push一个队列造成的不好影响。
cpp代码如下,这里的队列使用链表实现

#include 
#include 
#include 
#include 
#include 
#include 

using namespace std;


/*
问题:实现并发访问和处理的队列,队列接受字符串。生
产者4个线程,消费者4个线程,生产者从文件中读取文本行
并把它插入目标序号的队列。同时,消费者从队列重复地读取
message,并用split函数分词,在分词的过程中不断输出它,
直到生产者的文件都为空且消费者队列也被处理完毕。

分析:
每个消费者对应一个队列,不同队列可以被同时访问,但
一个队列不能同时被多个生产者访问,这里需要上特殊的线程锁。
生产者和生产者,消费者和消费者之间不需要同步,因为任务结束条件
都是一样的all(file clear and queue empty),只需要在8个线程的
任务结束阶段设置一个barrier即可。
允许队列被同时push和pop,如果用链表实现queue,则删除队首元素
和在队尾增加元素并不冲突,但是同时修改队列长度n会造成冲突,所以
我们用enqueue和dequeue两个参数描述队列长度,就不会造成冲突。

文件格式:文件两行为一组,第一行为0-3的编号,表示要发送到的目标
消费者线程;第二行为一段含空格的字符串,即需要发送的消息字段。
*/





void Split(string info) {
	//cout << info + '\n';
	string word;
	for (char ch : info) {
		if (ch != ' ')
			word += ch;
		else {
			cout << word + '\n';
			word.clear();
		}
	}
	cout << word + '\n';
}


struct Node
{
	string info;
	Node* next = nullptr;
};


struct Queue
{
	Node* head, *tail;
	int Enqueue, Dequeue;
	omp_lock_t lock;
	Queue()
	{
		head = tail = nullptr;
		Enqueue = Dequeue = 0;
		omp_init_lock(&lock);
	}
};

void push(Queue* q, string s) {
	Node* n = new Node;
	n->info = s;
	if (!q->tail) {
		q->head = n;
		q->tail = n;
	}
	else {
		Node* old_tail = q->tail;
		q->tail = n;
		old_tail->next = q->tail;
	}
	q->Enqueue++;
}

string pop(Queue* q) {
	if (!q->head)
		return "";
	else {
		Node* newHead = q->head->next;
		string msg = q->head->info;
		delete q->head;
		if (q->tail == q->head)
			q->tail = newHead;
		q->head = newHead;
		q->Dequeue++;
		return msg;
	}
}

static bool empty(Queue* q)
{
	return (q->head == nullptr);
}

int length(Queue* q) {
	Node* n = q->head;
	int len = 0;
	while (n)
	{
		n = n->next;
		len++;
	}
	return len;
}


bool Done(ifstream files[])
{
	for (int i = 0;i < 4;i++) {
		if (files[i].eof())
			continue;
		else
			return false;
	}
	return true;
}



static omp_lock_t lock, lock2;

int main()
{
	omp_init_lock(&lock);
	omp_init_lock(&lock2);
	ifstream files[4] = {
		ifstream("file0.txt"),
		ifstream("file1.txt"),
		ifstream("file2.txt"),
		ifstream("file3.txt")
	};
	Queue queues[4];
	//string msg;
	//while (getline(files[0], msg))
	//{
	//	cout << msg << endl;
	//}

#	pragma omp parallel num_threads(8)
	{
		int my_rank = omp_get_thread_num();
		if (my_rank < 4) {
			int dest;string msg;
			while (getline(files[my_rank], msg)) {
				dest = msg[0] - '0';
				getline(files[my_rank], msg);
				omp_set_lock(&(queues[dest].lock));
				push(&queues[dest], msg);
				//printf("Push line into queue %d\nNow length of queue %d is %d\n", dest, dest, length(&(queues[dest])));
				omp_unset_lock(&(queues[dest].lock));
			}
		}
		else {
			int num = my_rank - 4;
			string msg;
			while (!(Done(files) and empty(&queues[num]))) {
				if (empty(&queues[num]))
					continue;
				else {
					omp_set_lock(&(queues[num].lock));
					msg = pop(&queues[num]);
					Split(msg);
					omp_unset_lock(&(queues[num].lock));
					//printf("Queue %d has dealed message...\nlength is %d\n", num, length(&queues[num]));
				}
			}
		}
#		pragma omp barrier
	}
}



简单测试性能就可以发现,随着线程数增加,处理效率成倍提升。

总结

本文只是以队列为例子,介绍了OpenMP的使用和并行数据结构的设计方法。下一篇博客我会讲一下如何把常见的算法并行化。

你可能感兴趣的:(OpenMP并行程序设计——设计并行的数据结构)