《博学谷C++》四.1-8线程和线程同步

1.线程的概念

在许多经典的操作系统教科书中,总是把进程定义为程序的执行实例,它并不执行什么, 只是维护应用程序所需的各种资源,而线程则是真正的执行实体。进程为了完成一定的工作,必须包含至少一个线程。

进程拥有资源,而线程基本上不拥有资源,只有一点在运行中必不可少的资源(程序计数器、寄存器和栈),但可以和同一进程的其他线程共享进程的资源。

进程是CPU分配资源的最小单位,而线程是CPU调度的最小单位

如果说进程是一个资源管家,负责从主人那里要资源的话,那么线程就是干活的苦力。一个管家必须完成一项工作,就需要最少一个苦力,也就是说,一个进程最少包含一个线程,也可以包含多个线程。苦力要干活,就需要依托于管家,所以说一个线程,必须属于某一个进程。

2.linux线程的发展

最初linux实现线程采用的LinuxThreads并不能真正支持多线程,主要使用clone() 系统调用创造新进程作为可调度的实体,并且共享父进程的地址空间。总体LinuxThreads来说存在一些缺点。

后来NPTL(Native POSIX Thread Library)项目取代了LinuxThreads

3.线程特点

  1. 线程是轻量级进程(light-weight process),也有PCB,创建线程使用的底层函数和进程一样,都是clone。(也就是说,无论是创建进程的fork,还是创建线程的pthread_create,底层实现都是调用同一个内核函数 clone 。不同的是,复制对方的地址空间,那么就产生一个“进程”;共享对方的地址空间,那么就产生一个“线程”)

  2. 从内核里看进程和线程是一样的,都有各自不同的PCB。但是会有一个标志,标记出线程。

  3. 进程可以蜕变成线程

  4. 在linux下,线程最是小的执行单位;进程是最小的分配资源单位

Linux内核是不区分进程和线程的, 只在用户层面上进行区分。所以,线程所有操作函数 pthread_* 是库函数,而非系统调用。

4.线程共享资源

  1. 文件描述符表

  2. 每种信号的处理方式

  3. 当前工作目录

  4. 用户ID和组ID

内存地址空间 (.text/.data/.bss/heap/共享库)

5.线程非共享资源

  1. 线程id

  2. 处理器现场和栈指针(内核栈)

  3. 独立的栈空间(用户空间栈)

  4. errno变量

  5. 信号屏蔽字

  6. 调度优先级

6.多线程优缺点

优点:

Ø 提高程序并发性

Ø 开销小

Ø 数据通信、共享数据方便

缺点:

Ø 库函数,不稳定

Ø 调试、编写困难、gdb不支持

Ø 对信号支持不好

优点相对突出,缺点均不是硬伤。Linux下由于实现方法导致进程、线程差别不是很大。

一般用线程,如果有问题再用进程。

7.线程操作

1.线程号

进程号系统中唯一,但是线程号只在该进程中唯一。

进程号用 pid_t 数据类型表示,是一个非负整数;线程号则用 pthread_t 数据类型来表示,Linux 使用无符号长整数表示。

有的系统在实现pthread_t 的时候,用一个结构体来表示,所以在可移植的操作系统实现不能把它做为整数处理。

pthread_self函数:

1
2
3
4
5
6
7
8
9
#include <pthread.h>

pthread_t pthread_self(void);
功能:
获取线程号。
参数:

返回值:
调用线程的线程 ID 。

编译使用了thread相关函数的文件时,需要加上参数“-pthread”,一定要加,否则可能会报错“undefined reference to ‘pthread_create’”。

pthread_equal函数:

1
2
3
4
5
6
7
8
int pthread_equal(pthread_t t1, pthread_t t2);
功能:
判断线程号 t1 和 t2 是否相等。为了方便移植,尽量使用函数来比较线程 ID。
参数:
t1,t2:待判断的线程号。
返回值:
相等: 非 0
不相等:0

2.线程创建

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
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <pthread.h>

void *fun(){
printf("fun thread id: %u\n",pthread_self());
return NULL;
}

void *fun2(void* arg){
printf("fun2 thread id: %u\n",pthread_self());
printf("arg is %d\n", (int)(long)arg);
return NULL;
}



int main()
{
int ret = -1;
pthread_t tid = -1, tid2 = -1;

ret = pthread_create(&tid,NULL,fun,NULL);
if(ret != 0){
perror("pthread_create\n");
}

ret = pthread_create(&tid2,NULL,fun2,(void*)0x3);
if(ret != 0){
perror("pthread_create\n");
}
pthread_join(tid,NULL); //等待线程结束(此函数会阻塞),并回收线程资源
pthread_join(tid2,NULL);
printf("main thread id: %u\n",pthread_self());

return 0;
}

3.线程资源回收

使用pthread_join函数等待线程结束(此函数会阻塞),并回收线程资源

1
2
3
4
5
6
7
8
9
10
11
#include <pthread.h>

int pthread_join(pthread_t thread, void **retval);
功能:
等待线程结束(此函数会阻塞),并回收线程资源,类似进程的 wait() 函数。如果线程已经结束,那么该函数会立即返回。
参数:
thread:被等待的线程号。
retval:用来存储线程退出状态的指针的地址。
返回值:
成功:0
失败:非 0

上面的代码中已经使用过pthread_join

根据线程的结束方式,retval指针中存放不同的值。

验证线程共享进程资源:

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
#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <pthread.h>

int n = 100;

void *fun2(void* arg){
int *p = (int*) arg;
printf("fun2 before p=%d, n=%d\n",*p,n);
++(*p);
++n;
printf("fun2 after p=%d, n=%d\n",*p,n);
return NULL;
}



int main()
{
int ret = -1;
pthread_t tid = -1, tid2 = -1;

int *p = NULL;
p = malloc(sizeof(int));
if(p ==NULL){
printf("malloc failed.\n");
return 1;
}
*p = 1;


ret = pthread_create(&tid2,NULL,fun2,(void*)p);
if(ret != 0){
perror("pthread_create\n");
return 1;
}
pthread_join(tid2,NULL);
printf("main p=%d, n=%d\n",*p,n);

free(p);
p = NULL;

return 0;
}

运行结果

4.线程分离

一般情况下,线程终止后,其终止状态一直保留到其它线程调用pthread_join获取它的状态为止。但是线程也可以被置为detach状态,这样的分离后的线程一旦终止就立刻回收它占用的所有资源,而不保留终止状态。(并不是分离后线程不再依赖进程)

分离之后,不能再使用pthread_join()获取其状态。

1
2
3
4
5
6
7
8
9
10
#include <pthread.h>

int pthread_detach(pthread_t thread);
功能:
使调用线程与当前进程分离,分离后不代表此线程不依赖与当前进程,线程分离的目的是将线程资源的回收工作交由系统自动来完成,也就是说当被分离的线程结束之后,系统会自动回收它的资源。所以,此函数不会阻塞。
参数:
thread:线程号。
返回值:
成功:0
失败:非0

5.线程退出

线程退出不可以用exit(),因为这样会直接退出该进程,后续代码和其他线程等都不会被执行。

  • 线程从执行函数中return返回。
  • 线程调用pthread_exit退出线程。
  • 线程可以被同一进程中的其它线程取消。
1
2
3
4
5
6
7
8
#include <pthread.h>

void pthread_exit(void *retval);
功能:
退出调用线程。一个进程中的多个线程是共享该进程的数据段,因此,通常线程退出后所占用的资源并不会释放。
参数:
retval:存储线程退出状态的指针。
返回值:无

等价于return

6.线程取消

注意:线程的取消并不是实时的,而是有一定的延时。需要等待线程到达某个取消点(检查点)。

取消点:是线程检查是否被取消,并按请求进行动作的一个位置。通常是一些系统调用creat,open,pause,close,read,write….. 执行命令man 7 pthreads可以查看具备这些取消点的系统调用列表。

8.线程属性

线程属性一般使用默认参数;

对程序的性能提出更高的要求那么需要设置线程属性,比如可以通过设置线程栈的大小来降低内存的使用,增加最大线程个数。

线程属性:

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct
{
int etachstate; //线程的分离状态
int schedpolicy; //线程调度策略
struct sched_param schedparam; //线程的调度参数
int inheritsched; //线程的继承性
int scope; //线程的作用域
size_t guardsize; //线程栈末尾的警戒缓冲区大小
int stackaddr_set; //线程的栈设置
void* stackaddr; //线程栈的位置
size_t stacksize; //线程栈的大小
} pthread_attr_t;

主要结构体成员:

  1. 线程分离状态

  2. 线程栈大小(默认平均分配)

  3. 线程栈警戒缓冲区大小(位于栈末尾)

  4. 线程栈最低地址

1.流程:

  1. 在线程创建前,pthread_attr_init对线程属性初始化(之后才能设置);
  2. 在线程创建前,设置线程属性;
  3. 线程结束后,pthread_attr_destroy销毁线程属性。

2.线程分离状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <pthread.h>

int pthread_attr_setdetachstate(pthread_attr_t *attr, int detachstate);
功能:设置线程分离状态
参数:
attr:已初始化的线程属性
detachstate: 分离状态
PTHREAD_CREATE_DETACHED(分离线程)
PTHREAD_CREATE_JOINABLE(非分离线程)
返回值:
成功:0
失败:非0

int pthread_attr_getdetachstate(const pthread_attr_t *attr, int *detachstate);
功能:获取线程分离状态
参数:
attr:已初始化的线程属性
detachstate: 分离状态
PTHREAD_CREATE_DETACHED(分离线程)
PTHREAD _CREATE_JOINABLE(非分离线程)
返回值:
成功:0
失败:非0

3.线程栈地址

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#include <pthread.h>

int pthread_attr_setstack(pthread_attr_t *attr, void *stackaddr, size_t stacksize);
功能:设置线程的栈地址
参数:
attr:指向一个线程属性的指针
stackaddr:内存首地址
stacksize:返回线程的堆栈大小
返回值:
成功:0
失败:错误号

int pthread_attr_getstack(const pthread_attr_t *attr, void **stackaddr, size_t *stacksize);
功能:获取线程的栈地址
参数:
attr:指向一个线程属性的指针
stackaddr:返回获取的栈地址
stacksize:返回获取的栈大小
返回值:
成功:0
失败:错误号

4.线程栈大小

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <pthread.h>

int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);
功能:设置线程的栈大小
参数:
attr:指向一个线程属性的指针
stacksize:线程的堆栈大小
返回值:
成功:0
失败:错误号

int pthread_attr_getstacksize(const pthread_attr_t *attr, size_t *stacksize);
功能:获取线程的栈大小
参数:
attr:指向一个线程属性的指针
stacksize:返回线程的堆栈大小
返回值:
成功: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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63

#define SIZE 0x100000

void *th_fun(void *arg)
{
while (1)
{
sleep(1);
}
}

int main()
{
pthread_t tid;
int err, detachstate, i = 1;

pthread_attr_t attr;
size_t stacksize;
void *stackaddr;

pthread_attr_init(&attr); //线程属性初始化
pthread_attr_getstack(&attr, &stackaddr, &stacksize); //获取线程的栈地址
pthread_attr_getdetachstate(&attr, &detachstate); //获取线程分离状态

if (detachstate == PTHREAD_CREATE_DETACHED)
{
printf("thread detached\n");
}
else if (detachstate == PTHREAD_CREATE_JOINABLE)
{
printf("thread join\n");
}
else
{
printf("thread unknown\n");
}

pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); //设置分离状态

while (1)
{
stackaddr = malloc(SIZE);
if (stackaddr == NULL)
{
perror("malloc");
exit(1);
}

stacksize = SIZE;
pthread_attr_setstack(&attr, stackaddr, stacksize); //设置线程的栈地址
err = pthread_create(&tid, &attr, th_fun, NULL); //创建线程
if (err != 0)
{
printf("%s\n", strerror(err));
exit(1);
}
printf("%d\n", i++);
}

pthread_attr_destroy(&attr); //销毁线程属性所占用的资源函数

return 0;
}

9.线程注意事项

  1. 主线程退出其他线程不退出,主线程应调用pthread_exit

  2. 避免僵尸线程

a) pthread_join

b) pthread_detach

c) pthread_create指定分离属性

被join线程可能在join函数返回前就释放完自己的所有内存资源,所以不应当返回被回收线程栈中的值;

  1. malloc和mmap申请的内存可以被其他线程释放

  2. 应避免在多线程模型中调用fork,除非马上exec,子进程中只有调用fork的线程存在,其他线程t在子进程中均pthread_exit

  3. 信号的复杂语义很难和多线程共存,应避免在多线程引入信号机制

10.同步

  • 熟练掌握互斥量的使用
  • 说出什么叫死锁以及解决方案
  • 熟练掌握读写锁的使用
  • 熟练掌握条件变量的使用
  • 熟练掌握条件变量实现生产消费者模型
  • 熟练掌握信号量实现生产消费者模型

11.互斥

1.概念

现代操作系统基本都是多任务操作系统,即同时有大量可调度实体在运行。在多任务操作系统中,同时运行的多个任务可能:

  • 都需要访问/使用同一种资源
  • 多个任务之间有依赖关系,某个任务的运行依赖于另一个任务

这两种情形是多任务编程中遇到的最基本的问题,也是多任务编程中的核心问题,同步和互斥就是用于解决这两个问题的。

互斥:是指散步在不同任务之间的若干程序片断,当某个任务运行其中一个程序片段时,其它任务就不能运行它们之中的任一程序片段,只能等到该任务运行完这个程序片段后才可以运行。最基本的场景就是:一个公共资源同一时刻只能被一个进程或线程使用,多个进程或线程不能同时使用公共资源。

同步:是指散步在不同任务之间的若干程序片断,它们的运行必须严格按照规定的某种先后次序来运行,这种先后次序依赖于要完成的特定的任务。最基本的场景就是:两个或两个以上的进程或线程在运行过程中协同步调,按预定的先后次序运行。比如 A 任务的运行依赖于 B 任务产生的数据。

显然,同步是一种更为复杂的互斥,而互斥是一种特殊的同步。也就是说互斥是两个任务之间不可以同时运行,他们会相互排斥,必须等待一个线程运行完毕,另一个才能运行,而同步也是不能同时运行,但他是必须要按照某种次序来运行相应的线程(也是一种互斥)!因此互斥具有唯一性和排它性,但互斥并不限制任务的运行顺序,即任务是无序的,而同步的任务之间则有顺序关系。

2.使用函数

restrict,C语言中的一种类型限定符(Type Qualifiers),用于告诉编译器,对象已经被指针所引用,不能通过除该指针外所有其他直接或间接的方式修改该对象的内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <pthread.h>

int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr);
功能:
初始化一个互斥锁。
参数:
mutex:互斥锁地址。类型是 pthread_mutex_t
attr:设置互斥量的属性,通常可采用默认属性,即可将 attr 设为 NULL

可以使用宏 PTHREAD_MUTEX_INITIALIZER 静态初始化互斥锁,比如:
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

这种方法等价于使用 NULL 指定的 attr 参数调用 pthread_mutex_init() 来完成动态初始化,不同之处在于 PTHREAD_MUTEX_INITIALIZER 宏不进行错误检查。

返回值:
成功:0,成功申请的锁默认是打开的。
失败:非 0 错误码
1
2
3
4
5
6
7
8
9
10
#include <pthread.h>

int pthread_mutex_destroy(pthread_mutex_t *mutex);
功能:
销毁指定的一个互斥锁。互斥锁在使用完毕后,必须要对互斥锁进行销毁,以释放资源。
参数:
mutex:互斥锁地址。
返回值:
成功:0
失败:非 0 错误码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include <pthread.h>

int pthread_mutex_lock(pthread_mutex_t *mutex);
功能:
对互斥锁上锁,若互斥锁已经上锁,则调用者阻塞,直到互斥锁解锁后再上锁。
参数:
mutex:互斥锁地址。
返回值:
成功:0
失败:非 0 错误码

int pthread_mutex_trylock(pthread_mutex_t *mutex);
 调用该函数时,若互斥锁未加锁,则上锁,返回 0
 若互斥锁已加锁,则函数直接返回失败,即 EBUSY。

1
2
3
4
5
6
7
8
9
10
#include <pthread.h>

int pthread_mutex_unlock(pthread_mutex_t *mutex);
功能:
对指定的互斥锁解锁。
参数:
mutex:互斥锁地址。
返回值:
成功:0
失败:非0错误码

3.死锁

必要条件

  • 互斥条件

    某资源只能被一个进程使用,其他进程请求该资源时,只能等待,直到资源使用完毕后释放资源。

  • 请求和保持条件

    程序已经保持了至少一个资源,但是又提出了新要求,而这个资源被其他进程占用,自己占用资源却保持不放。

  • 不可抢占条件

    进程已获得的资源没有使用完,不能被抢占。

  • 循环等待条件

    必然存在一个循环链。

处理死锁思路

  • 预防死锁

    破坏死锁的四个必要条件中的一个或多个来预防死锁。

  • 避免死锁

    和预防死锁的区别就是,在资源动态分配过程中,用某种方式防止系统进入不安全的状态。

  • 检测死锁

    运行时出现死锁,能及时发现死锁,把程序解脱出来

  • 解除死锁

    发生死锁后,解脱进程,通常撤销进程,回收资源,再分配给正处于阻塞状态的进程。

预防方法

  • 破坏请求和保持条件

协议1:

所有进程开始前,必须一次性地申请所需的所有资源,这样运行期间就不会再提出资源要求,破坏了请求条件,即使有一种资源不能满足需求,也不会给它分配正在空闲的资源,这样它就没有资源,就破坏了保持条件,从而预防死锁的发生。

协议2:

允许一个进程只获得初期的资源就开始运行,然后再把运行完的资源释放出来。然后再请求新的资源。

  • 破坏不可抢占条件

当一个已经保持了某种不可抢占资源的进程,提出新资源请求不能被满足时,它必须释放已经保持的所有资源,以后需要时再重新申请。

  • 破坏循环等待条件

对系统中的所有资源类型进行线性排序,然后规定每个进程必须按序列号递增的顺序请求资源。假如进程请求到了一些序列号较高的资源,然后有请求一个序列较低的资源时,必须先释放相同和更高序号的资源后才能申请低序号的资源。多个同类资源必须一起请求。

12.读写锁

互斥锁排他性,只能有一个线程访问资源,而多个线程读资源并不会产生问题。

读写锁允许多个线程读,只能同时有一个线程写。

读写锁的特点如下:

1)如果有其它线程读数据,则允许其它线程执行读操作,但不允许写操作。

2)如果有其它线程写数据,则其它线程都不允许读、写操作。

读写锁分为读锁和写锁,规则如下:

1)如果某线程申请了读锁,其它线程可以再申请读锁,但不能申请写锁。

2)如果某线程申请了写锁,其它线程不能申请读锁,也不能申请写锁。

(允许多个读锁,只能有一个写锁,写锁优先级高于读锁)

1.函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <pthread.h>

int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,
const pthread_rwlockattr_t *restrict attr);
功能:
用来初始化 rwlock 所指向的读写锁。

参数:
rwlock:指向要初始化的读写锁指针。
attr:读写锁的属性指针。如果 attr 为 NULL 则会使用默认的属性初始化读写锁,否则使用指定的 attr 初始化读写锁。

可以使用宏 PTHREAD_RWLOCK_INITIALIZER 静态初始化读写锁,比如:
pthread_rwlock_t my_rwlock = PTHREAD_RWLOCK_INITIALIZER;

这种方法等价于使用 NULL 指定的 attr 参数调用 pthread_rwlock_init() 来完成动态初始化,不同之处在于PTHREAD_RWLOCK_INITIALIZER 宏不进行错误检查。

返回值:
成功:0,读写锁的状态将成为已初始化和已解锁。
失败:非 0 错误码。

1
2
3
4
5
6
7
8
9
10
#include <pthread.h>

int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
功能:
用于销毁一个读写锁,并释放所有相关联的资源(所谓的所有指的是由 pthread_rwlock_init() 自动申请的资源) 。
参数:
rwlock:读写锁指针。
返回值:
成功:0
失败:非 0 错误码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#include <pthread.h>

int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock);
功能:
以阻塞方式在读写锁上获取读锁(读锁定)。
如果没有写者持有该锁,并且没有写者阻塞在该锁上,则调用线程会获取读锁。
如果调用线程未获取读锁,则它将阻塞直到它获取了该锁。一个线程可以在一个读写锁上多次执行读锁定。
线程可以成功调用 pthread_rwlock_rdlock() 函数 n 次,但是之后该线程必须调用 pthread_rwlock_unlock() 函数 n 次才能解除锁定。
参数:
rwlock:读写锁指针。
返回值:
成功:0
失败:非 0 错误码

int pthread_rwlock_tryrdlock(pthread_rwlock_t *rwlock);
 用于尝试以非阻塞的方式来在读写锁上获取读锁。
 如果有任何的写者持有该锁或有写者阻塞在该读写锁上,则立即失败返回。

12.条件变量

与互斥锁不同,条件变量是用来等待而不是用来上锁的,条件变量本身不是锁

条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。通常条件变量和互斥锁同时使用。

条件变量的两个动作:

  • 条件不满, 阻塞线程
  • 当条件满足, 通知阻塞的线程开始工作

条件变量的类型: pthread_cond_t。