本篇博文转自http://zhangxiaoya.github.io/2015/05/15/multi-thread-of-c-program-language-on-linux/
Linux下提供了多种方式来处理线程同步,最常用的是互斥锁、条件变量、信号量和读写锁。
下面是思维导图:
简介
进程— 资源分配的最小单位
线程— 程序执行的最小单位
进程是一个程序的一个实例,拥有自己独立的各种段(数据段,代码段等等),每次创建一个进程需要从操作系统分配这些资源给他,消耗一定的时间,在linux下C语言创建一个进程使用fork()
函数;
线程是一个轻量级的进程,除了自己少数的资源,不用用其他资源,且一个进程可以创建多个线程,这些线程共享进程的资源,创建线程的时间要比创建进程少很多,(几十分之一),从函数角度是使用pthread_create()
创建。
使用线程处理文件I/O或者socket处理都是非常有优势的,将一个大人物分解成若干个小任务,每个线程处理一个任务,线程之间切换不需要花很多时间,而且线程之间数据交换很方便,共享存储区。
C语言中使用多线程的函数
表 1. 线程函数列表
对象 | 操作 | Linux Pthread API | Windows SDK 库对应 API |
---|---|---|---|
线程 | 创建 | pthread_create | CreateThread |
退出 | pthread_exit | ThreadExit | |
等待 | pthread_join | WaitForSingleObject | |
互斥锁 | 创建 | pthread_mutex_init | CreateMutex |
销毁 | pthread_mutex_destroy | CloseHandle | |
加锁 | pthread_mutex_lock | WaitForSingleObject | |
解锁 | pthread_mutex_unlock | ReleaseMutex | |
条件 | 创建 | pthread_cond_init | CreateEvent |
销毁 | pthread_cond_destroy | CloseHandle | |
触发 | pthread_cond_signal | SetEvent | |
广播 | pthread_cond_broadcast | SetEvent / ResetEvent | |
等待 | pthread_cond_wait / pthread_cond_timedwait | SingleObjectAndWait |
多线程开发在 Linux 平台上已经有成熟的 Pthread 库支持。其涉及的多线程开发的最基本概念主要包含四点:线程,互斥锁,条件变量、读写锁。其中,线程操作又分线程的创建,退出,等待 3 种。互斥锁则包括 4 种操作,分别是创建,销毁,加锁和解锁。条件操作有 5 种操作:创建,销毁,触发,广播和等待。其他的一些线程扩展概念,如信号灯等,都可以通过上面的三个基本元素的基本操作封装出来。
创建线程
int pthread_create(pthread_t * tid, const pthread_attr_t * attr, void * ( * func) (void * ), void * arg);
其返回值是一个整数,若创建进程成功返回0,否则,返回其他错误代码,也是正整数。
创建线程需要的参数:
- 线程变量名:
pthread_t *
类型,是标示线程的id,一般是无符号整形,这里也可以是引用类型,目的是用于返回创建线程的ID - 线程的属性指针:制定线程的属性,比如线程优先*级,初始栈大小等,通常情况使用的都是指针。
- 创建线程的程序代码:一般是函数指针,进程创建后执行该函数指针只想的函数。
- 程序代码的参数:若线程执行的函数包含由若干个参数,需要将这些参数封装成结构体,并传递给它指针。
创建线程的函数的形式如下:
结束线程
结束进程的函数定义如下:
void pthread_exit (void *status);
参数是指针类型,用于存储线程结束后返回状态。
线程等待
int pthread_join (pthread_t tid, void ** status);
- 第一个参数表示要等待的进程的id;
- 第二参数表示要等待的进程的返回状态,是个二级指针。
线程创建后怎么执行,新线程和老线程谁先执行这些不是程序来决定,而是由操作系统进行调度的,但是在编程的时候我们常常需要多个线程配合工作,比如在结束某个线程之前,需要等待另外一个线程的处理结果(返回状态等信息),这时候就需要使用线程等待函数,这个函数的定义如下:
其他关于进程的函数
-
返回当前线程ID
pthread_t pthread_self (void);
用于返回当前进程的ID -
制定线程变成分裂状态
int pthread_detach (pthread_t tid);
参数是指定线程的ID,指定的ID的线程变成分离状态;若指定线程是分离状态,则 如果线程退出,那么它所有的资源都将释放,如果线程不是分离状态,线程必须保留它的线程ID、退出状态,直到其他线程对他调用的pthread_join()
函数
参考实例一
代码如下:
-
#include <stdio.h>
-
#include <pthread.h>
-
#include <stdlib.h>
-
#include <unistd.h>
-
void print_message_func(void *ptr);
-
int main()
-
{
-
int tmp1,tmp2;
-
void *retival;
-
pthread_t thread1,thread2;
-
char *message1 = "thread1";
-
char *message2 = "thread2";
-
int ret_thread1,ret_thread2;
-
ret_thread1 = pthread_create(&thread1,NULL,(void *)&print_message_func,(void *)message1);
-
if(ret_thread1 == 0)
-
printf("create thread 1 true\n");
-
else
-
printf("create thread 1 false\n");
-
tmp1 = pthread_join(thread1,&retival);
-
printf("thread 1 return value (tmp1) is %d\n",tmp1);
-
if(tmp1 != 0)
-
printf("cannot join with thread 1\n");
-
ret_thread2 = pthread_create(&thread2,NULL,(void *)&print_message_func,(void *)message2);
-
if(ret_thread2 == 0)
-
printf("create thread 2 true\n");
-
else
-
printf("create thread 2 false\n");
-
tmp2 = pthread_join(thread2,&retival);
-
printf("thread 2 return value (tmp2) is %d\n",tmp2);
-
if(tmp2 != 0)
-
printf("cannot join with thread 2\n");
-
}
-
void print_message_func(void *ptr)
-
{
-
for(int i=0;i<5;++i)
-
{
-
printf("%s:%d\n",(char*)ptr,i);
-
}
-
}
这个代码比较简单,就是演示这几个常用函数的使用。
这里是纯C语言程序,在Linux下的编译命令是gcc test.c -o test -lpthread
,运行程序是./test
,后面的程序同样
多线程的同步与互斥
锁机制
多线程之间可能需要互斥的访问一些全局变量,这就需要互斥的来访问,这些需要共享访问的字段被称作是临界资源,访问临界资源的程序段称作是临界区。
实现线程间的互斥与同步机制的是锁机制,下面是常用的锁机制的函数和类。
pthread_mutex_t mutex
锁对象pthread_mutex_init(&mutex,NULL)
在主线程中初始化锁为解锁状态pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
编译时初始化锁位解锁状态pthread_mutex_lock(&mutex)(阻塞加锁)
访问临界区加锁操作- pthread_mutex_trylock( &mutex)(非阻塞加锁); pthread_mutex_lock() 类似,不同的是在锁已经被占据时返回 EBUSY 而不是挂起等待。
pthread_mutex_unlock(&mutex)
: 访问临界区解锁操作
参考实例二(不加锁访问互斥全局变量)
-
#include <stdio.h>
-
#include <stdlib.h>
-
#include <pthread.h>
-
int sharei = 0;
-
void increase_num(void);
-
int main()
-
{
-
int ret;
-
pthread_t thread1,thread2,thread3;
-
ret = pthread_create(&thread1,NULL,(void *)&increase_num,NULL);
-
ret = pthread_create(&thread2,NULL,(void *)&increase_num,NULL);
-
ret = pthread_create(&thread3,NULL,(void *)&increase_num,NULL);
-
pthread_join(thread1,NULL);
-
pthread_join(thread2,NULL);
-
pthread_join(thread3,NULL);
-
printf("sharei = %d\n",sharei);
-
return 0;
-
}
-
void increase_num(void)
-
{
-
long i,tmp;
-
for(i =0;i<=10000;++i)
-
{
-
tmp = sharei;
-
tmp = tmp + 1;
-
sharei = tmp;
-
}
-
}
编译运行结果,多运行几次,发现结果都不一样。这就是因为对于全局变量,没有添加互斥锁,导致的问题。
参考实例三 (访问全局变量添加互斥锁)
-
#include <stdio.h>
-
#include <stdlib.h>
-
#include <pthread.h>
-
int sharei = 0;
-
void increase_num(void);
-
// add mutex
-
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;
-
int main()
-
{
-
int ret;
-
pthread_t thread1,thread2,thread3;
-
ret = pthread_create(&thread1,NULL,(void *)&increase_num,NULL);
-
ret = pthread_create(&thread2,NULL,(void *)&increase_num,NULL);
-
ret = pthread_create(&thread3,NULL,(void *)&increase_num,NULL);
-
pthread_join(thread1,NULL);
-
pthread_join(thread2,NULL);
-
pthread_join(thread3,NULL);
-
printf("sharei = %d\n",sharei);
-
return 0;
-
}
-
void increase_num(void)
-
{
-
long i,tmp;
-
for(i =0;i<=10000;++i)
-
{
-
// lock
-
if(pthread_mutex_lock(&mutex) != 0)
-
{
-
perror("pthread_mutex_lock");
-
exit(EXIT_FAILURE);
-
}
-
tmp = sharei;
-
tmp = tmp + 1;
-
sharei = tmp;
-
// unlock
-
if(pthread_mutex_unlock(&mutex) != 0)
-
{
-
perror("pthread_mutex_unlock");
-
exit(EXIT_FAILURE);
-
}
-
}
-
}
添加互斥锁后,就发现,多次运行的结果都是一样的。
- 其实这里的加锁不是对共享变量(全局变量)或者共享内存进行保护,这里的加锁实际上是对临界区的控制,所谓的临界区就是访问临界资源的那一段代码,这段代码对临界资源进行多种操作,正确的情况是不允许这段代码执行到一半,处理器使用权就被其他线程抢走,所以这段代码具有原子性,即要么执行,要么不执行,不能执行到一半就被抢走处理权,这样就会造成共享数据被污染。
- 还有一点,添加锁来控制临界区是有代价的,这个代价表现出来就是时间的额外开销,内部过程是因为要保护现场,会利用一些资源,也需要处理器处理的时间。
信号量机制
锁机制使用是有限制的,锁只有两种状态,即加锁和解锁,对于互斥的访问一个全局变量,这样的方式还可以对付,但是要是对于其他的临界资源,比如说多台打印机等,这种方式显然不行了。
信号量机制在操作系统里面学习的比较熟悉了,信号量是一个整数计数器,其数值表示空闲临界资源的数量。
当有进程释放资源时,信号量增加,表示可用资源数增加;当有进程申请到资源时,信号量减少,表示可用资源数减少。这个时候可以把锁机制认为是0-1信号量。
关于信号量机制的函数。
int sem_init(sem_t * sem, int pshared, unsigned int value);
初始化信号量
-
- 成功返回0,失败返回-1;
-
- 参数sem:表示指向信号结构的指针。
-
- 参数pshared:不是0 的时候该信号量在进程间共享,否则只能在当前进程的所有线程间共享。
-
- 参数value:信号量的初始值。
int sem_wait(sem_t *sem);
信号量减一操作,有线程申请资源
-
- 成功返回0,否则返回-1
-
- 参数sem:指向一个信号量的指针
int sem_post(sem_t *sem);
信号量加一操作,有线程释放资源
-
- 成功返回0,否则返回-1
-
- 参数sem:指向一个信号量指针
int sem_destroy(sem_t *sem);
销毁信号量。
-
- 成功返回0,否则返回-1
-
- 参数sem:指向一个信号量的指针。
参考实例四(生产者消费者)
-
#include <stdio.h>
-
#include <stdlib.h>
-
#include <pthread.h>
-
#include <semaphore.h>
-
#define MAXSIZE 10
-
int stack[MAXSIZE];
-
int size =0;
-
sem_t sem;
-
void privide_data(void)
-
{
-
int i;
-
for(i =0;i<MAXSIZE;++i)
-
{
-
stack[i] = i;
-
sem_post(&sem);
-
}
-
}
-
void handle_data(void)
-
{
-
int i;
-
while((i = size ++) <MAXSIZE)
-
{
-
sem_wait(&sem);
-
printf("cross : %d X %d = %d \n",stack[i],stack[i],stack[i] * stack[i]);
-
sleep(1);
-
}
-
}
-
int main()
-
{
-
pthread_t privider,handler;
-
sem_init(&sem,0,0);
-
pthread_create(&privider,NULL,(void *)&privide_data,NULL);
-
pthread_create(&handler,NULL,(void *)&handle_data,NULL);
-
pthread_join(privider,NULL);
-
pthread_join(handler,NULL);
-
sem_destroy(&sem);
-
return 0;
-
}
这段代码是经典的生产者消费者问题,只有当生产者把资源放入存储区,消费者才能取得。
附:(知识点)
Linux 线程编程中的 5 条经验
尽量设置 recursive 属性以初始化 Linux 的互斥变量
互斥锁是多线程编程中基本的概念,在开发中被广泛使用。其调用次序层次清晰简单:建锁,加锁,解锁,销毁锁。但是需要注意的是,与诸如 Windows 平台的互斥变量不同,在默认情况下,Linux 下的同一线程无法对同一互斥锁进行递归加速,否则将发生死锁。
所谓递归加锁,就是在同一线程中试图对互斥锁进行两次或两次以上的行为。其场景在 Linux 平台上的代码可由清单 1 所示。
清单 1. Linux 重复对互斥锁加锁实例
1 2 3 4 5 6 7 8 9 10 11 12 |
|
在以上代码场景中,问题将出现在第二次加锁操作。由于在默认情况下,Linux 不允许同一线程递归加锁,因此在第二次加锁操作时线程将出现死锁。
Linux 互斥变量这种奇怪的行为或许对于特定的某些场景会所有用处,但是对于大多数情况下看起来更像是程序的一个 bug 。毕竟,在同一线程中对同一互斥锁进行递归加锁在尤其是二次开发中经常会需要。
这个问题与互斥锁的中的默认 recursive 属性有关。解决问题的方法就是显式地在互斥变量初始化时将设置起 recursive 属性。基于此,以上代码其实稍作修改就可以很好的运行,只需要在初始化锁的时候加设置一个属性。请看清单 2 。
清单 2. 设置互斥锁 recursive 属性实例
1 2 3 4 |
|
因此,建议尽量设置 recursive 属性以初始化 Linux 的互斥锁,这样既可以解决同一线程递归加锁的问题,又可以避免很多情况下死锁的发生。这样做还有一个额外的好处,就是可以让 Windows 和 Linux 下让锁的表现统一。
注意 Linux 平台上触发条件变量的自动复位问题
条件变量的置位和复位有两种常用模型:第一种模型是当条件变量置位(signaled)以后,如果当前没有线程在等待,其状态会保持为置位(signaled),直到有等待的线程进入被触发,其状态才会变为复位(unsignaled),这种模型的采用以 Windows 平台上的 Auto-set Event 为代表。其状态变化如图 1 所示:
图 1. Windows 的条件变量状态变化流程
第二种模型则是 Linux 平台的 Pthread 所采用的模型,当条件变量置位(signaled)以后,即使当前没有任何线程在等待,其状态也会恢复为复位(unsignaled)状态。其状态变化如图 2 所示:
图 2. Linux 的条件变量状态变化流程
具体来说,Linux 平台上 Pthread 下的条件变量状态变化模型是这样工作的:调用 pthread_cond_signal() 释放被条件阻塞的线程时,无论存不存在被阻塞的线程,条件都将被重新复位,下一个被条件阻塞的线程将不受影响。而对于 Windows,当调用 SetEvent 触发 Auto-reset 的 Event 条件时,如果没有被条件阻塞的线程,那么条件将维持在触发状态,直到有新的线程被条件阻塞并被释放为止。
这种差异性对于那些熟悉 Windows 平台上的条件变量状态模型而要开发 Linux 平台上多线程的程序员来说可能会造成意想不到的尴尬结果。试想要实现一个旅客坐出租车的程序:旅客在路边等出租车,调用条件等待。出租车来了,将触发条件,旅客停止等待并上车。一个出租车只能搭载一波乘客,于是我们使用单一触发的条件变量。这个实现逻辑在第一个模型下即使出租车先到,也不会有什么问题,其过程如图 3 所示:
图 3. 采用 Windows 条件变量模型的出租车实例流程
然而如果按照这个思路来在 Linux 上来实现,代码看起来可能是清单 3 这样。
清单 3. Linux 出租车案例代码实例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
|
好的,运行一下,看看结果如清单 4 。
清单 4. 程序结果输出
1 2 3 4 |
|
其过程如图 4 所示:
图 4. 采用 Linux 条件变量模型的出租车实例流程
通过对比结果,你会发现同样的逻辑,在 Linux 平台上运行的结果却完全是两样。对于在 Windows 平台上的模型一, Jack 开着出租车到了站台,触发条件变量。如果没顾客,条件变量将维持触发状态,也就是说 Jack 停下车在那里等着。直到 Susan 小姐来了站台,执行等待条件来找出租车。 Susan 搭上 Jack 的出租车离开,同时条件变量被自动复位。
但是到了 Linux 平台,问题就来了,Jack 到了站台一看没人,触发的条件变量被直接复位,于是 Jack 排在等待队列里面。来迟一秒的 Susan 小姐到了站台却看不到在那里等待的 Jack,只能等待,直到 Mike 开车赶到,重新触发条件变量,Susan 才上了 Mike 的车。这对于在排队系统前面的 Jack 是不公平的,而问题症结是在于 Linux 平台上条件变量触发的自动复位引起的一个 Bug 。
条件变量在 Linux 平台上的这种模型很难说好坏。但是在实际开发中,我们可以对代码稍加改进就可以避免这种差异的发生。由于这种差异只发生在触发没有被线程等待在条件变量的时刻,因此我们只需要掌握好触发的时机即可。最简单的做法是增加一个计数器记录等待线程的个数,在决定触发条件变量前检查下该变量即可。改进后 Linux 函数如清单 5 所示。
清单 5. Linux 出租车案例代码实例
-
……
-
// 提示出租车到达的条件变量
-
pthread_cond_t taxiCond;
-
// 同步锁
-
pthread_mutex_t taxiMutex;
-
// 旅客人数,初始为 0
-
int travelerCount=0;
-
// 旅客到达等待出租车
-
void * traveler_arrive(void * name) {
-
cout<< ” Traveler: ” <<(char *)name<< ” needs a taxi now! ” <<endl;
-
pthread_mutex_lock(&taxiMutex);
-
// 提示旅客人数增加
-
travelerCount++;
-
pthread_cond_wait (&taxiCond, &taxiMutex);
-
pthread_mutex_unlock (&taxiMutex);
-
cout<< ” Traveler: ” << (char *)name << ” now got a taxi! ” <<endl;
-
pthread_exit( (void *)0 );
-
}
-
// 出租车到达
-
void * taxi_arrive(void *name)
-
{
-
cout<< ” Taxi ” <<(char *)name<< ” arrives. ” <<endl;
-
while(true)
-
{
-
pthread_mutex_lock(&taxiMutex);
-
// 当发现已经有旅客在等待时,才触发条件变量
-
if(travelerCount>0)
-
{
-
pthread_cond_signal(&taxtCond);
-
pthread_mutex_unlock (&taxiMutex);
-
break;
-
}
-
pthread_mutex_unlock (&taxiMutex);
-
}
-
pthread_exit( (void *)0 );
-
}
注意条件返回时互斥锁的解锁问题因此我们建议在 Linux 平台上要出发条件变量之前要检查是否有等待的线程,只有当有线程在等待时才对条件变量进行触发。
在 Linux 调用 pthread_cond_wait 进行条件变量等待操作时,我们增加一个互斥变量参数是必要的,这是为了避免线程间的竞争和饥饿情况。但是当条件等待返回时候,需要注意的是一定不要遗漏对互斥变量进行解锁。
Linux 平台上的 pthread_cond_wait(pthread_cond_t *cond, pthread_mutex_t *mutex) 函数返回时,互斥锁 mutex 将处于锁定状态。因此之后如果需要对临界区数据进行重新访问,则没有必要对 mutex 就行重新加锁。但是,随之而来的问题是,每次条件等待以后需要加入一步手动的解锁操作。正如前文中乘客等待出租车的 Linux 代码如清单 6 所示:
清单 6. 条件变量返回后的解锁实例
1 2 3 4 5 6 7 8 |
|
等待的绝对时间问题
超时是多线程编程中一个常见的概念。例如,当你在 Linux 平台下使用 pthread_cond_timedwait() 时就需要指定超时这个参数,以便这个 API 的调用者最多只被阻塞指定的时间间隔。pthread_cond_timedwait() 函数定义
1 2 3 |
|
参数 abstime 在这里用来表示和超时时间相关的一个参数,但是需要注意的是它所表示的是一个绝对时间,而不是一个时间间隔数值,只有当系统的当前时间达到或者超过 abstime 所表示的时间时,才会触发超时事件。
相对时间到绝对时间转换实例
1 2 3 4 5 6 7 |
|
Linux 的绝对时间看似简单明了,却是开发中一个非常隐晦的陷阱。而且一旦你忘了时间转换,可以想象,等待你的错误将是多么的令人头疼:如果忘了把相对时间转换成绝对时间,相当于你告诉系统你所等待的超时时间是过去式的 1970 年 1 月 1 号某个时间段,于是操作系统毫不犹豫马上送给你一个 timeout 的返回值,然后你会举着拳头抱怨为什么另外一个同步线程耗时居然如此之久,并一头扎进寻找耗时原因的深渊里。
正确处理 Linux 平台下的线程结束问题
在 Linux 平台下,当处理线程结束时需要注意的一个问题就是如何让一个线程善始善终,让其所占资源得到正确释放。在 Linux 平台默认情况下,虽然各个线程之间是相互独立的,一个线程的终止不会去通知或影响其他的线程。但是已经终止的线程的资源并不会随着线程的终止而得到释放,我们需要调用 pthread_join() 来获得另一个线程的终止状态并且释放该线程所占的资源。 Pthread_join() 函数的定义:
1 |
|
调用该函数的线程将挂起,等待 th 所表示的线程的结束。 thread_return 是指向线程 th 返回值的指针。需要注意的是 th 所表示的线程必须是 joinable 的,即处于非 detached(游离)状态;并且只可以有唯一的一个线程对 th 调用 pthread_join() 。如果 th 处于 detached 状态,那么对 th 的 pthread_join() 调用将返回错误。
如果你压根儿不关心一个线程的结束状态,那么也可以将一个线程设置为 detached 状态,从而来让操作系统在该线程结束时来回收它所占的资源。将一个线程设置为 detached 状态可以通过两种方式来实现。一种是调用 pthread_detach() 函数,可以将线程 th 设置为 detached 状态。
pthread_detach 函数定义:
1 |
|
另一种方法是在创建线程时就将它设置为 detached 状态,首先初始化一个线程属性变量,然后将其设置为 detached 状态,最后将它作为参数传入线程创建函数 pthread_create(),这样所创建出来的线程就直接处于 detached 状态。方法如下。
创建 detach 线程代码实例:
1 |
|
总之为了在使用 Pthread 时避免线程的资源在线程结束时不能得到正确释放,从而避免产生潜在的内存泄漏问题,在对待线程结束时,要确保该线程处于 detached 状态,否着就需要调用 pthread_join() 函数来对其进行资源回收。
总结与补充
本文以上部分详细介绍了 Linux 的多线程编程的 5 条高效开发经验。另外你也可以考虑尝试其他一些开源类库来进行线程开发。
1. Boost 库
Boost 库来自于由 C++ 标准委员会类库工作组成员发起,致力于为 C++ 开发新的类库的 Boost 组织。虽然该库本身并不是针对多线程而产生,但是发展至今,其已提供了比较全面的多线程编程的 API 支持。 Boost 库对于多线程支持的 API 风格上更类似于 Linux 的 Pthread 库,差别在于其将线程,互斥锁,条件等线程开发概念都封装成了 C++ 类,以方便开发调用。 Boost 库目前对跨平台支持的很不错,不仅支持 Windows 和 Linux ,还支持各种商用的 Unix 版本。如果开发者想使用高稳定性的统一线程编程接口减轻跨平台开发的难度, Boost 库将是首选。
2. ACE
ACE 全称是 ADAPTIVE Communication Environment,它是一个免费的,开源的,面向对象的工具框架,用以开发并发访问的软件。由于 ACE 最初是面向网络服务端的编程开发,因此对于线程开发的工具库它也能提供很全面的支持。其支持的平台也很全面,包括 Windows,Linux 和各种版本 Unix 。 ACE 的唯一问题是如果仅仅是用于线程编程,其似乎显得有些过于重量级。而且其较复杂的配置也让其部署对初学者而言并非易事。