多线程编程(C++)

多线程

早期计算机只允许一个程序独占资源,一次只能执行一个程序。计算力是一种宝贵的资源。

这种背景下,多程序并发执行的需求十分迫切,由此产生了进程的概念。进程在多数早期操作系统中是执行工作的基本单元。进程是包含程序和资源的集合,每个程序与其他程序一起参与调度,竞争CPU、内存等系统资源。每次进程切换都存在资源的保存和恢复,这被称为上下文切换。进程的引入解决了多用户支持的问题,但是产生了新的问题:进程频繁切换引起的额外开销严重影响系统性能。进程通信要求复杂的系统级实现。

例如一个简单的GUI任务,通常一个任务支持界面交互、一个任务支持后台运算。如果每个任务都由一个进程来实现会相当的低效。对每一个进程来说,系统资源看上去都是独占的,比如内存空间。这样演化利用分配给统一个进程实现多个任务的方法。同一个进程内部的线程共享进程的所有资源。共享这些内存空间,比如定义个全局变量,A将其赋值为1,B看到这个变量也是1。线程很方便的支持了进程内部的并发,避免了频繁切换的开销。

多线程

一个程序的运行中,只有一个控制权存在。但函数被调用时,该函数获得控制权成为激活函数,各个函数像是连在一条线上,计算机流水线执行操作,这样叫做单线程序。

多线程就是允许一个进程存在多个控制权,同时有多一个函数处于激活状态。单线程中函数会被压栈,只有栈顶函数被调用,而多线程则会在内存中存在多个栈。

多线程的创建与结束

线程的创建与结束

1
2
3
#include <pthread>

int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);

pthread_t 实际上就是 unsigned long int . 第一个参数指向线程标识符,第二个参数设置线程属性,第三个参数是线程运行函数的起始地址, 最后一个参数是运行函数的参数。

若创建成功则返回0, 否则返回错误号。

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
#include <stdio.h>
#include <pthread.h>
#include <string.h>

struct arg_type{
int a;
char b[100];
};

void* say_hello(void* args){
arg_type arg_temp = *(arg_type*)args;
printf("hello from thread, info: %d, %s\n pthread=%lu\n",arg_temp.a, arg_temp.b, pthread_self());
pthread_exit((void*)1);
}

int main(){
pthread_t tid;
arg_type arg_temp;
arg_temp.a = 10;
char temp[100] = "To be number one";
strncpy(arg_temp.b, temp, sizeof(temp));
int iRet = pthread_create(&tid, NULL, say_hello, &arg_temp);
if(iRet){
printf("pthread_create error");
return iRet;
}
printf("Thread id in process: %lu\n", tid);
void *retval;
iRet = pthread_join(tid, &retval);
if(iRet){
printf("pthread_join error");
return iRet;
}
printf("retavl\n");
return 0;
}

线程的属性

线程有一组属性可以在线程被创建时指定,该属性封装在一个对象中,对象的类型为pthread_attr_t

属性值不能直接设置,必须使用相关的函数操作,这个函数必须在 pthread_create 之前调用并通过pthread_attr_destory释放,主要属性包括:作用域、栈尺寸、栈地址、优先级、分离状态、调度策略和参数。

POSIX.1 之定义一系列属性,主要如下表

  1. 分离状态:若线程终止,线程处于分离状态,系统不保留线程的终止状态;当不需要线程的终止状态时,可以分类线程。
  2. 栈地址:设置和获取线程的栈地址
  3. 栈大小:系统中有很多线程时,可能需要减少每个线程栈的默认大小,防止进程的地址空间不够用。
  4. 栈保护区大小:在线程顶留出一段空间,防止栈溢出。
  5. 线程优先级,新线程的默认优先级是0
  6. 继承父进程的优先级:新线程不继承父进程的优先级
  7. 调度策略
  8. 争用范围
  9. 线程并行级别,POSIX 标准定义了3中调度策略:先入先出策略(FIFO)、循环策略(RR)和自定义策略(OTHER)。FIFO是基于队列的调度程序,对于每个优先级使用不同的队列。

FIFO :如果继承具有有效的用户ID为0,则争用范围为系统的先入先出属于实时调度类,如果这些线程未被更高级的线程抢占,则会继续处理该线程,直到线程放弃或阻塞为止。

多线程同步

多线程相当于一个并发系统,一般通知执行多个任务,如果多个任务可以共享资源,就需要解决同步问题。

线程火车票

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
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

int total_ticket_num = 20;
void *sell_ticket(void *arg){
int id = *(int*)arg;
for(int i=0; i<20; i++){
if(total_ticket_num > 0){
sleep(1);
printf("from id: %d, sell the tikect %dth ticket\n", id, 20-total_ticket_num+1);
total_ticket_num--;
}
}
return 0;
}
int main(){
int iRet;
pthread_t tids[4];
int i = 0;
for(int i=0; i<4; i++){
sleep(1);
int iRet = pthread_create(&tids[i], NULL, &sell_ticket, &i);
if(iRet){
printf("pthread_create error, iRet=%d\n",iRet); return iRet;
}
}
sleep(1);
void *retval;
for(int i=0;i<4;i++){
iRet = pthread_join(tids[i], &retval);
if(iRet){
printf("pthread_join error, iRet=%d\n",iRet); return iRet;
}
printf("retval=%ld\n", (long)retval);
}
return 0;
}

事实上,如果只有一个线程执行上面的程序,没有问题。但是如果多个线程都执行就会出现问题。其根本原因在于各个线程都可以对 total_ticket_num 进行写入。

这里if会判断是否有剩余票,如果有则卖。但是不同的线程之间会存在时间窗口,其他线程可能在这个窗口进行卖票操作,导致卖票的条件不成立,但是线程已经进行了判断,所以无法知道 total_ticket_num 发生了变化。

在并发的情况下,指令的先后顺序由内核决定。同一个线程内部指令按照先后顺序执行,但不同的线程很难说哪一个先执行。如果运行的结果依赖于不同线程执行的先后顺序,就会造成竞争条件。这样条件下计算机的计算结果未知。所以应该避免竞争条件的形成。

对于多线程程序来说,同步是指在某一定时间内只允许一个线程访问资源。可以通过互斥锁、条件变量、读写锁和信号量来同步资源。

同步锁

互斥锁 是一个特殊的变量,他有lock和unlock两个状态。互斥锁一般被设置为全局变量。打开的互斥锁可以由某个线程获得,一旦获得互斥锁会被锁上,只有该线程有权打开。

火车票系统的互斥系统可以被这样表示

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
42
43
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <pthread.h>
#include <unistd.h>

pthread_mutex_t mutex_x = PTHREAD_MUTEX_INITIALZER;
int total_ticket_num = 20;
void *sell_ticket(void *arg){
int id = *(int*)arg;
for(int i=0; i<20; i++){
pthread_mutex_lock(&mutex_x);
if(total_ticket_num > 0){
sleep(1);
printf("from id: %d, sell the tikect %dth ticket\n", id, 20-total_ticket_num+1);
total_ticket_num--;
}
pthread_mutex_unlock(&mutex_x);
}
return 0;
}
int main(){
int iRet;
pthread_t tids[4];
int i = 0;
for(int i=0; i<4; i++){
sleep(1);
int iRet = pthread_create(&tids[i], NULL, &sell_ticket, &i);
if(iRet){
printf("pthread_create error, iRet=%d\n",iRet); return iRet;
}
}
sleep(1);
void *retval;
for(int i=0;i<4;i++){
iRet = pthread_join(tids[i], &retval);
if(iRet){
printf("pthread_join error, iRet=%d\n",iRet); return iRet;
}
printf("retval=%ld\n", (long)retval);
}
return 0;
}

第一个执行pthread_mutex_lock()的线程会首先获得mutex_x ,其他线程必须等待,直到第一个线程释放后,其他线程才可以获得锁并继续执行。

条件变量

互斥是线程程序必备的工具,但是并非万能。例如,如果线程等待共享数据某个条件出现,它可能重复对互斥对象锁定和解锁,每次都会检查共享数据结构,这样查询效率很低。

每次检查之间,可以使线程短暂的进入睡眠,但是这样无法立即响应。真正需要的一种方法是:当线程满足某些条件时使得线程进入睡眠状态,一旦条件满足就唤醒睡眠的线程。这正是条件变量的任务。

条件变量通过允许线程阻塞和等待另一个线程信号方法弥补互斥锁的不足,他常常和互斥锁一起使用。使用时条件变量被用于阻塞一个线程,一旦某个线程改变了条件变量,它将通知相应的条件变量唤醒一个或多个阻塞的线程,这些线程会重新测试是否满足条件。

条件变量例子

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
42
43
44
45
46
47
48
49
#include <iostream>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
using namespace std;

pthread_cond_t qready = PTHREAD_COND_INITIALIZER;
pthread_mutex_t qlock = PTHREAD_MUTEX_INITIALIZER;
int x = 10;
int y = 20;
void *func1(void* args){
cout<<"func1 start"<<endl;
pthread_mutex_lock(&qlock);
while(x<y){
pthread_cond_wait(&qready, &qlock);
}
pthread_mutex_unlock(&qlock);
sleep(3);
cout<<"func1 end"<<endl;
}
void *func2(void* args){
cout<<"func2 start"<<endl;
pthread_mutex_lock(&qlock);
x = 20;
y = 10;
cout<<"has change x and y"<<endl;
pthread_mutex_unlock(&qlock);
if(x>y){
pthread_cond_signal(&qready);
}
cout<<"func2 end"<<endl;
}
int main(){
pthread_t tid1, tid2;
int iRet;
iRet = pthread_create(&tid1, NULL, func1, NULL);
if(iRet){
cout<<"pthread 1 create error"<<endl;
return iRet;
}
sleep(2);
iRet = pthread_create(&tid2, NULL, func2, NULL);
if(iRet){
cout<<"pthread 1 create error"<<endl;
return iRet;
}
sleep(10);
return 0;
}

读写锁

某些程序存在读者写者问题,对某些资源的访问可能有两种情况,一种是排他性的,必须独占,这被称为写操作;一种是可以共享的,被称为读操作。

  1. 读写锁比互斥锁具有更高的适应性与并行性,可以有多个线程同时占用读写锁,但是只有一个线程占用写模式的读写锁。
  • 当读写锁是 写加锁 状态时,其他试图加锁的程序都会被阻塞
  • 当读写锁在 读加锁状态是,所有读模式加速线程可以被授权,但是写模式加锁会被阻塞
  • 读写锁在 读加锁 模式时,如果有试图写加锁的请求,后续的读模式会被阻塞, 以避免长期的读模式占用,而 等待写模式的请求则长期阻塞。

读写锁最适用于对数据 读操作多于写操作的场合,应为读模式可以共享,而写模式只能由某个线程独占,因而读写锁也叫 共享-独占锁。

处理读者、写者问题两种常见的步骤是强读者同步和强写者同步。强读者同步给于读更高的优先权,强写着同步给与写更高的优先权。航班订票–强写者同步 图书馆查询–强读者同步

读写者例子

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
42
#include <stdio.h>
#include <pthread.h>
#include <stdlib.h>
#include <unistd.h>
#define THREADNUM 5
pthread_rwlock_t rwlock;
void *readers(void *args){
pthread_rwlock_rdlock(&rwlock);
printf("reader %ld got the lock\n", (long)args);
pthread_rwlock_unlock(&rwlock);
pthread_exit((void*)0);
}
void *writers(void *args){
pthread_rwlock_wrlock(&rwlock);
printf("reader %ld got the lock\n", (long)args);
pthread_rwlock_unlock(&rwlock);
pthread_exit((void*)0);
}
int main(){
int iRet, i;
pthread_t writer_id, reader_id;
pthread_attr_t attr;
int nreadercount = 1, nwritercount = 1;
iRet = pthread_rwlock_init(&rwlock, NULL);
if(iRet){
printf("ERROR");
return iRet;
}
pthread_attr_init(&attr);
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
for(int i=0; i<THREADNUM; i++){
if(i%3){
pthread_create(&reader_id, &attr, readers, (void*)nreadercount);
printf("create reader %d\n", nreadercount++);
}else{
pthread_create(&reader_id, &attr, writers, (void*)nwritercount);
printf("create writer %d\n", nwritercount++);
}
}
sleep(5);
return 0;
}

信号量

线程还可以通过信号量通信,信号量和互斥锁的区别: 互斥锁只允许 一个线程进入临界区,而信号量允许多个线程进入临界区,原理同互斥锁

多线程重入

前面介绍的各种同步方式,其实就是为了解决“函数不可重入”问题。所谓“可重入”函数是指多于一个任务并发使用而不必担心错误的函数。而不可重入函数只能由一个函数独占,除非确保函数互斥。

  1. 可重入函数有以下特点
  • 不为连续调用持有静态数据
  • 不返回指向静态的指针
  • 所有的户数都由函数的调用者提供
  • 使用本地数据,或者通过制作全局数据的本地副本来保护全局数据
  • 如果必须访问全局变量,利用互斥锁、信号量来保护全局变量
  • 绝不调用任何不可重用函数
  1. 不可重用函数有以下特点
  • 函数中存在静态变量,无论是全局还是局部静态变量
  • 函数返回静态变量、
  • 函数中掉用了不可重用函数
  • 函数体使用了静态数据结构
  • 函数体掉用了malloc或free函数
  • 函数体内掉用了其他标准的IO函数