1 线程(熟悉)
进程:程序是存放在存储介质上的一个可执行文件,而进程是程序执行的过程。进程的状态是变化的,其包括进程的创建、调度和消亡。因此程序是静态的,进程是动态的。是CPU分配资源的最小单位
线程:线程(thread)是操作系统能够进行运算调度的最小单位,它被包含在进程之中,是进程中的实际运作单位。线程是CPU调度的最小单位
1.1 进程和线程的区别
- 进程,直观点说,保存在硬盘上的程序运行以后,会在内存空间里形成一个独立的内存体,这个内存体有自己的地址空间,有自己的堆,上级挂靠单位是操作系统。操作系统会以进程为单位,分配系统资源,所以我们也说,进程是CPU分配资源的最小单位。
线程:线程存在与进程当中(进程可以认为是线程的容器),线程是操作系统调度执行的最小单位。线程自己基本上不拥有系统资源,只拥有一点在运行中必不可少的资源(如程序计数器,一组寄存器和栈),但是它可与同属一个进程的其他的线程共享进程所拥有的全部资源,共享全局内存区域,包括初始话数据段data、未初始化数据段bss和堆内存段。(是为避免进程fork()产生写时拷贝的不必要时间和内存支出)
- 关系:
- 一个进程可有多个线程
- 进程间的共享数据要依靠进程间通信工具,而线程在共享的全局内存区域很容易实现数据共享
- 进程比线程消耗更多的计算机资源
- 进程使用的内存地址可以上锁,即一个线程使用某些共享内存时,其他线程必须等它结束,才能使用这一块内存。
- 进程间不会相互影响,而一个线程挂掉将导致整个进程挂掉
1.2 线程概览
POSIX C的线程库pthread
API定义了一干数据类型,下面列出了其中的一部分: 进程可蜕变为线程。实际上,无论是创建进程的fork,还是创建线程的pthread_create,底层实现都是调用同一个内核函数clone
。
- ** Ø 如果复制对方的地址空间,那么就产出一个“进程”;--->深拷贝**
- ** Ø 如果共享对方的地址空间,就产生一个“线程”。--->浅拷贝**
Linux内核是不区分进程和线程的, 只在用户层面上进行区分。所以,线程所有操作函数pthread_*
是库函数,而非系统调用。
- 线程共享资源包括有:
- 文件描述符表
- 每种信号的处理方式
- 当前工作目录
- 用户ID和组ID
- 5)内存地址空间
(.text/.data/.bss/heap/共享库)
- 线程非共享资源:
- 线程id
- 处理器现场和栈指针(内核栈)
- 独立的线程栈空间(用户空间栈)
- errno变量
- 信号屏蔽字
- 调度优先级
注:Linux中系统调用的错误都存储于errno
中,errno
由操作系统维护是一个全局整型变量,存储就近发生的错误,即下一次的错误码会覆盖掉上一次的错误。errno
是一个包含在<errno.h>
中的预定义的外部int变量,用于表示最近一个函数调用是否产生了错误。但是显然,errno
在多线程上会发生竞争,因此为适应多线程,各个线程独立拥有errno
。简言概之,就是errno机制在保留Linux的报错方式同时,也是适应了多线程环境。
1.3 线程优缺点
- 优点:
- Ø 提高程序并发性。
- Ø 开销小,共享全局内存区域
- Ø 数据通信、共享数据方便
- 缺点:
- Ø 库函数,不稳定
- Ø 调试、编写困难、gdb不支持
- Ø 对信号支持不好
优点相对突出,缺点均不是硬伤。Linux下由于实现方法导致进程、线程差别不是很大。 注意:gcc编译时要链接 -pthread 1
gcc 5pthread_self.c -pthread
1.4 线程常用API
1.4.1 pthread_self:获取线程号
线程号只在它所属的进程环境中有效。一般来说应把pthread_t
当作一结构体,而不是整型 1
2
3
4
5
6
pthread_t pthread_self(void);
/*功能:
获取线程号
返回值:调用线程的线程ID
*/
1.4.2 pthread_equal:线程号比较
1 |
|
1.4.3 pthread_create:线程创建
在一个线程中调用pthread_create()
创建新的线程后,当前线程从pthread_create()
返回继续往下执行,而新的线程所执行的代码由我们传给pthread_create
的函数指针start_routine
决定。 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23//回调函数
void* thread_fun(void* arg)
{
sleep(1);
int num=*((int *)arg);
printf("int the new thread:num=%d\n",num);
return NULL;
}
int main(){
pthread_t tid;
int test =100;
//创建线程
int ret = pthread_create(&tid,NULL,thread_fun,(void*)&test);
if(ret!=0)
{
printf("error number:%d\n",ret);
//根据错误信号打印错误信息
printf("error information:%s\n",strerror(ret));
}
while(1);
return 0;
}pthread_create
的错误码不保存在errno
中,因此不能直接用perror()
打印错误信息,可以先用strerror()
把错误码转换成错误信息再打印。
1.4.4 pthread_join线程资源回收
调用该函数的线程将挂起等待,直到id为tid的线程终止。tid线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:
- 如果
tid
线程通过return返回,retval
所指向的单元里存放的是tid
线程函数的返回值。
- 如果
- 如果
thread
线程被别的线程调用pthread_cancel
异常终止掉,retval
所指向的单元里存放的是常数PTHREAD_CANCELED。
- 如果
- 如果
thread
线程是自己调用pthread_exit
终止的,retval
所指向的单元存放的是传给pthread_exit
的参数。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19void * thead(void* arg)
{
static int num =123;
printf("after 2 seconds, thread will return\n");
sleep(2);
return #
}
int main(){
pthread_t tid;
void * value=NULL;
int ret=0;
//创建线程
pthread_create(&tid,NULL,thread_fun,NULL);
//等待线程号为tid的线程,如果此线程结束就回收其资源
//&value保存线程退出的返回值
pthread_join(tid,&value);
printf("value=%d\n",*(int*)value);
return 0;
}
- 如果
1.4.5 pthread_detach:线程分离
一般情况下,线程终止后,其终止状态一直保留到其它线程调用pthread_join获取它的状态为止。但是线程也可以被置为detach状态,这样的线程一旦终止就立刻回收它占用的所有资源,而不保留终止状态
不能对一个已经处于detach状态的线程调用pthread_join,这样的调用将返回EINVAL错误。也就是说,如果已经对一个线程调用了pthread_detach就不能再调用pthread_join了。
1.4.6 pthread_exit线程退出
在进程中我们可以调用exit函数或_exit函数来结束进程,在一个线程中我们可以通过以下三种在不终止整个进程的情况下停止它的控制流。
- 线程从执行函数中返回。
- 线程调用
pthread_exit
退出线程。 - 线程可以被同一进程中的其它线程取消。
1.4.7 pthread_cancle
线程的取消并不是实时的,而又一定的延时。需要等待线程到达某个取消点(检查点)。 1
2
3
4
5
6
7
8
9
int pthread_cancel(pthread_t thread);
/*
功能:杀死线程
参数:thread:目标线程ID
返回值:
成功:0
失败:错误编号
*/
1.5 线程属性
Linux下线程的属性是可以根据实际项目需要,进行设置,之前我们讨论的线程都是采用线程的默认属性,默认属性已经可以解决绝大多数开发时遇到的问题。
如我们对程序的性能提出更高的要求那么需要设置线程属性,比如可以通过设置线程栈的大小来降低内存的使用,增加最大线程个数。 主要结构体成员:
- 线程分离状态
- 线程栈大小(默认平均分配)
- 线程栈警戒缓冲区大小(位于栈末尾)
- 线程栈最低地址
属性值不能直接设置,须使用相关函数进行操作,初始化的函数为pthread_attr_init
,这个函数必须在pthread_create
函数之前调用。之后须用pthread_attr_destroy
函数来释放资源。
1.5.1 线程分离状态
线程的分离状态决定一个线程以什么样的方式来终止自己。
非分离状态:线程的默认属性是非分离状态,这种情况下,原有的线程等待创建的线程结束。只有当pthread_join()函数返回时,创建的线程才算终止,才能释放自己占用的系统资源。
分离状态:分离线程没有被其他的线程所等待,自己运行结束了,线程也就终止了,马上释放系统资源。应该根据自己的需要,选择适当的分离状态。
2. 线程同步之互斥锁
在多核CPU中,同时运行的多个任务可能:
- 都需要访问/使用同一种资源,但对该资源操作不是原子操作,可能出现不可预知的错误
- 多个任务之间有依赖关系,某个任务的运行依赖于另一个任务
同步和互斥就是用于解决这两个问题的。
互斥:是指散步在不同任务之间的若干程序片断,当某个任务运行其中一个程序片段时,其它任务就不能运行它们之中的任一程序片段,只能等到该任务运行完这个程序片段后才可以运行。最基本的场景就是:一个公共资源同一时刻只能被一个进程或线程使用,多个进程或线程不能同时使用公共资源。
同步:是指散步在不同任务之间的若干程序片断,它们的运行必须严格按照规定的某种先后次序来运行,这种先后次序依赖于要完成的特定的任务。最基本的场景就是:两个或两个以上的进程或线程在运行过程中协同步调,按预定的先后次序运行。比如 A 任务的运行依赖于 B 任务产生的数据。
2.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
static int glob=0;
static void* threadFunc(void* arg){
int loops =*((int*)arg);
int loc,j;
for(j=0;j<loops;j++){
loc=glob;
loc++;
glob=loc;
}
return NULL;
}
int main(int argc,cahr* argv[])
{
pthread_t t1,t2;
int loops,s;
loops=10000;
s=pthread_create(&t1,NULL,threadFunc,&loops);
s=pthread_create(&t2,NULL,threadFunc,&loops);
pthread_detach(t1);
pthread_detach(t2);
printf("glob=%d\n",glob);
exit(EXIT_SUCCESS);
}glob
按正常来说应该输出为20000,但是输出却是少于这个数的,就是因为多线程对共享区域执行非原子操作时出现的问题,必须使用互斥量
线程里有这么一把锁叫互斥锁(mutex
),也叫互斥量,互斥锁是一种简单的加锁的方法来控制对共享资源的访问,互斥锁只有两种状态,即加锁(lock
)和解锁(unlock
)。互斥锁的操作流程如下:
- 1)在访问共享资源后临界区域前,对互斥锁进行加锁。
- 2)在访问完成后释放互斥锁导上的锁。
- 3)对互斥锁进行加锁后,任何其他试图再次对互斥锁加锁的线程将会被阻塞,直到锁被释放。
互斥锁的数据类型是: pthread_mutex_t
2.2 互斥锁常用函数
2.2.1 pthread_mutex_init()函数:初始化一个互斥锁
restrict
,C语言中的一种类型限定符(Type Qualifiers),用于告诉编译器,对象已经被指针所引用,不能通过除该指针外所有其他直接或间接的方式修改该对象的内容。
2.2.2 pthread_mutex_destroy函数:销毁互斥锁
2.2.3 pthread_mutex_lock函数:上锁
2.2.4 pthread_mutex_unlock函数:解锁
2.2.5 示例:打印机
1 |
|
2.3 死锁
死锁是指两个或两个以上的线程在执行过程中,由于竞争资源或者由于彼此通信而造成的一种阻塞的现象,若无外力作用,它们都将无法推进下去。此时称系统处于死锁状态或系统产生了死锁,这些永远在互相等待的进程称为死锁进程。
2.3.1 死锁引起的原因
- 竞争不可抢占资源引起死锁(不可抢占是指没有使用完的资源,不能被抢占)
- 竞争可消耗资源引起死锁:有p1,p2,p3三个进程,p1向p2发送消息并接受p3发送的消息,p2向p3发送消息并接受p1的消息,p3向p1发送消息并接受p2的消息,如果设置是先接到消息后发送消息,则所有的消息都不能发送,这就造成死锁。
- 进程推进顺序不当引起死锁:有进程p1,p2,都需要资源A,B,本来可以p1运行A --> p1运行B --> p2运行A --> p2运行B,但是顺序换了,p1运行A时p2运行B,容易发生第一种死锁。互相抢占资源。
2.3.2 死锁的必要条件
- 互斥条件:某资源只能被一个进程使用,其他进程请求该资源时,只能等待,直到资源使用完毕后释放资源。
- 请求和保持条件:程序已经保持了至少一个资源,但是又提出了新要求,而这个资源被其他进程占用,自己占用资源却保持不放。
- 不可抢占条件:进程已获得的资源没有使用完,不能被抢占。
- 循环等待条件:必然存在一个循环链。
2.3.3 预防死锁的思路
- 预防死锁:破坏死锁的四个必要条件中的一个或多个来预防死锁。
- 避免死锁:和预防死锁的区别就是,在资源动态分配过程中,用某种方式防止系统进入不安全的状态。
- 检测死锁:运行时出现死锁,能及时发现死锁,把程序解脱出来
- 解除死锁:发生死锁后,解脱进程,通常撤销进程,回收资源,再分配给正处于阻塞状态的进程。
2.3.4 预防死锁的方法
- 破坏请求和保持条件
- 协议1:所有进程开始前,必须一次性地申请所需的所有资源,这样运行期间就不会再提出资源要求,破坏了请求条件,即使有一种资源不能满足需求,也不会给它分配正在空闲的资源,这样它就没有资源,就破坏了保持条件,从而预防死锁的发生。
- 协议2:允许一个进程只获得初期的资源就开始运行,然后再把运行完的资源释放出来。然后再请求新的资源。
** 破坏不可抢占条件**:当一个已经保持了某种不可抢占资源的进程,提出新资源请求不能被满足时,它必须释放已经保持的所有资源,以后需要时再重新申请 。
破坏循环等待条件:对系统中的所有资源类型进行线性排序,然后规定每个进程必须按序列号递增的顺序请求资源。假如进程请求到了一些序列号较高的资源,然后有请求一个序列较低的资源时,必须先释放相同和更高序号的资源后才能申请低序号的资源。多个同类资源必须一起请求。
3 线程同步之读写锁
实际上多个线程同时读访问共享资源并不会导致问题。但互斥锁的排他性,导致其它进程或线程无法读取,为克服这个缺陷,就引入了读写锁。 在对数据的读写操作中,更多的是读操作,写操作较少,例如对数据库数据的读写应用。为了满足当前能够允许多个读出,但只允许一个写入的需求,线程提供了读写锁来实现。
3.1 读写锁的特点
- 1)如果有其它线程读数据,则允许其它线程执行读操作,但不允许写操作。
- 2)如果有其它线程写数据,则其它线程都不允许读、写操作。
读写锁分为读锁和写锁,规则如下:
- 1)如果某线程申请了读锁,其它线程可以再申请读锁,但不能申请写锁。
- 2)如果某线程申请了写锁,其它线程不能申请读锁,也不能申请写锁。
POSIX 定义的读写锁的数据类型是: pthread_rwlock_t
3.2 读写锁常用函数
3.2.1 pthread_rwlock_init函数:初始化读写锁
3.2.2 pthread_rwlock_destroy函数:销毁读写锁
3.2.3 pthread_rwlock_rdlock函数:在读写锁上获取读锁
3.2.4 pthread_rwlock_wrlock函数:获取写锁
3.2.5 pthread_rwlock_unlock函数:解锁
4 线程同步之条件变量
与互斥锁不同,条件变量是用来等待而不是用来上锁的,条件变量本身不是锁!条件变量用来自动阻塞一个线程,直到某特殊情况发生为止。通常条件变量和互斥锁同时使用。条件变量就共享变量的状态改变发出通知,而互斥量则提供对该共享变量访问的互斥。条件变量的类型: pthread_cond_t
条件变量的两个动作:
- 条件不满, 阻塞线程
- 当条件满足, 通知阻塞的线程开始工作
条件变量的优点是相较于mutex而言,条件变量可以减少竞争提升效率。如直接使用mutex,除了生产者、消费者之间要竞争互斥量以外,消费者之间也需要竞争互斥量,且如果汇聚(链表)中没有数据,消费者之间竞争互斥锁是无意义的还会浪费CPU资源。有了条件变量机制以后,只有生产者完成生产,才会引起消费者之间的竞争。提高了程序效率。
4.1 条件变量常用函数
4.1.1 pthread_cond_init函数:初始化条件变量
4.1.2 pthread_cond_destroy函数:销毁条件变量
4.1.3 pthread_cond_wait函数:阻塞等待
abstime补充说明:
4.1.4 pthread_cond_signal函数:唤醒至少一个阻塞在条件变量上的线程
4.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
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74//节点结构
typedef struct node{
int data;
struct node* next;
}Node;
//永远指向链表头部的指针
Node* head=NULL;
//线程同步-互斥锁
pthread_mutex_t mutex;
//阻塞线程-条件变量
pthread_cond_t cond;
//生产者
void* producer(void* arg)
{
while(1)
{
//创建一个链表的节点
Node* pnew =(Node*)malloc(seziof(Node));
//节点初始化
pnew->data=rand()%1000;
//使用互斥锁保护共享数据
pthread_mutex_lock(&mutex);
pnew->next=head;
head=pnew;
printf("=====produce:%lu,%d\n",pthread_self(),pnew->data);
pthread_mutex_unlock(&mutex);
//通知条件变量阻塞线程,解除阻塞
pthread_cond_signal(&cond);
sleep(rand()%3);
}
return NULL;
}
//消费者
void* customer(void* arg){
while(1){
pthread_mutex_lock(&mutex);
//判断链表是否为空,空则阻塞等待
if(head==NULL)
{
//线程阻塞,释放互斥锁
pthread_cond_wait(&cond,&mutex);
//解除阻塞后,对互斥锁进行加锁
}
Node* pdel=head;
head=head->next;
printf("----customer:%lu,&d\n",pthread_self(),pdel->data);
free(pdel);
pthread_mutex_unlock(&mutex);
}
return NULL;
}
//主函数入口
int main(int argc,char* argv[])
{
pthread_t p1,p2;
//init
pthread_mutex_init(&mutex,NULL);
pthread_cond_init(&cond,NULL);
//创建生产者线程
pthread_create(&p1,NULL,producer,NULL);
//消费者线程
pthread_create(&p2,NULL,customer,NULL);
//阻塞回收子线程
pthread_join(p1,NULL);
pthread_join(p2,NULL);
//销毁互斥锁、条件变量
pthread_mutex_destroy(&mutex);
pthread_mutex_destory(&cond);
return 0;
}
5 信号量
在进程模块我们已经介绍了POSIX信号量,这里我们再做一次介绍。
信号量广泛用于进程或线程间的同步和互斥,信号量本质上是一个非负的整数计数器,它被用来控制对公共资源的访问。编程时可根据操作信号量值的结果判断是否对公共资源具有访问的权限,当信号量值大于 0 时,则可以访问,否则将阻塞。
PV原语是对信号量的操作,一次 P 操作使信号量减1,一次 V 操作使信号量加1。信号量主要用于进程或线程间的同步和互斥这两种典型情况。
信号量数据类型为:sem_t
5.1 信号量用于同步和互斥
信号量可用于同步和互斥情况,其用于两种环境分别如下所示:
- 互斥
- 同步
5.2 未命名信号量常用函数
5.2.1 sem_init函数:初始化
5.2.2 sem_destroy函数:销毁
5.2.3 sem_wait函数:信号量p操作减1
abs_timeout
补充说明: 1
2
3
4
5
6
7
8struct timespec{
time_t tv_sec; //秒
long tv_nsec; //纳秒
};
time_t cur=time(NULL);
struct timespec t;
t.tv_sec=cur+1;
sem_timedwait(&cond,&t);
5.2.4 sem_post函数:信号量v操作
5.2.5 sem_getvalue:获取信号量的值
5.3 示例:打印机
1 |
|
6 自旋锁
- 自旋锁:
spinlock
,在任何时刻同样只能有一个线程访问对象。但是当获取锁操作失败时,不会进入睡眠,而是会在原地自旋,直到锁被释放。这样节省了线程从睡眠状态到被唤醒期间的消耗,在加锁时间短暂的环境下会极大的提高效率。但如果加锁时间过长,则会非常浪费CPU资源。
7 线程安全
- 线程安全:当一个执行区域可供多个线程同时执行,没有出现错误时,我们成为线程安全的。换句话说就是函数可同时供多个线程同时调用,则为线程安全函数。
下面代码就不是线程安全,因为当多个线程并发调用该函数时,glob
的最终值不得而知,通常出现线程不安全的原因是因为使用了全局或静态变量: 1
2
3
4
5
6
7
8
9
10
11
12
static int glob=0;
static void* threadFunc(void* arg){
int loops =*((int*)arg);
int loc,j;
for(j=0;j<loops;j++){
loc=glob;
loc++;
glob=loc;
}
return NULL;
}
- 仅在函数中操作共享变量的代码前后加入互斥量,这样能实现大部分的线程安全;但由于互斥锁的加、解锁开销,也就带来了性能的下降。
- 如果能避免使用全局或静态变量,可重入函数则无需使用互斥量即可实现线程安全
- 对于单核CPU时,只需要保证对共享变量是原子操作即可保证线程安全;但多核CPU,则不行
7.1 可重入不不可重入
不可重入函数:不同任务调用这个函数时可能修改其他任务调用这个函数的数据,从而导致不可预料的后果。这样的函数是不安全的函数。如使用了静态的数据解够、malloc()和free、以及标准的I/O函数(因为带缓冲区)都是不可重入函数。
可重入函数:函数在被多个进程调度时,不必担心数据出错
- 保证函数的可重入性的方法:
- 在写函数时候尽量使用局部变量(例如寄存器、栈中的变量);
- 对于要使用的全局变量要加以保护(如采取关中断、信号量等互斥方法),这样构成的函数就一定是一个可重入的函数。