多线程编程与锁


  在多进程中,每个进程互相独立,不影响主程序的稳定性,子进程崩溃没关系; 通过增加 CPU,就可以容易扩充性能 ;可以尽量减少线程加锁/解锁的影响,极大提高性能,就算是线程运行的模块算法效率低也没关系; 每个子进程都有2GB地址空间和相关资源,总体能够达到的性能上限非常大,但是,逻辑控制复杂,需要和主程序交互; 需要跨进程边界,如果有大数据量传送,就不太好,适合小数据量传送、密集运算 多进程调度开销比较大; 所以,我们可以使用到多线程。

一、多线程的优缺点

优点:

1)它是一种非常"节俭"的多任务操作方式。在Linux系统下,启动一个新的进程必须分配给它独立的地址空间,建立众多的数据表来维护它的代码段、堆栈段和数据段,这是一种"昂贵"的多任务工作方式。而运行于一个进程中的多个线程,它们彼此之间使用相同的地址空间,共享大部分数据,启动一个线程所花费的空间远远小于启动一个进程所花费的空间,而且,线程间彼此切换所需的时间也远远小于进程间切换所需要的时间。当然,在具体的系统上,这个数据可能会有较大的区别;
2)线程间方便的通信机制,由于同一进程下的线程之间共享数据空间,所以一个线程的数据可以直接为其它线程所用,这不仅快捷,而且方便;
3)使多CPU系统更加有效。操作系统会保证当线程数不大于CPU数目时,不同的线程运行于不同的CPU上;
4)改善程序结构。一个既长又复杂的进程可以考虑分为多个线程,成为几个独立或半独立的运行部分,这样的程序会利于理解和修改。

缺点:

1)每个线程与主程序共用地址空间,受限于2GB地址空间;
2)线程之间的同步和加锁控制比较麻烦;
3)一个线程的崩溃可能影响到整个程序的稳定性;
4)到达一定的线程数程度后,即使再增加CPU也无法提高性能;
5)线程能够提高的总性能有限,而且线程多了之后,线程本身的调度也是一个麻烦事儿,需要消耗较多的CPU

接下来,我们就来具体了解线程的概念与简单的使用。

二、浅识线程

1.线程模型

前面提到,线程工作与同一个进程空间,且子线程与主线程共享资源,他的模型是这样的

在这里插入图片描述
main() 函数中的线程称为主线程,而主线程又可以创建多个子线程,子线程之间有不同的任务工作,而具体做什么任务,而执行该任务又需要哪些参数,这得主线程在创建他们的时候传入;
线程可以提高应用程序在多核环境下处理诸如文件I/O或者socket I/O等会产生堵塞的情况的表现性能。在Unix系统中,一个进程包含很多东西,包括可执行程序以及一大堆的诸如文件描述符地址空间等资源。在很多情况下,完成相关任务的不同代码间需要交换数据。如果采用多进程的方式,进程的创建所花的时间片要比线程大些,另外进程间的通信比较麻烦,需要在用户空间和内核空间进行频繁的切换,开销很大。但是如果使用多线程的方式,因为可以使用共享的全局变量,所以线程间的通信(数据交换)变得非常高效。

2.线程属性与函数

pthread_attr_t

在创建线程之前,我们需要定义一个 pthread_attr_t 类型的结构体,
该结构体成员如下:


typedef struct
{
        int detachstate; 线程的分离状态  
        int schedpolicy; 线程调度策略
        struct sched_param schedparam; 线程的调度参数
        int inheritsched; 线程的继承性
        int scope; 线程的作用域
        size_t guardsize; 线程栈末尾的警戒缓冲区大小
        int stackaddr_set;
        void * stackaddr; 线程栈的位置
        size_t stacksize; 线程栈的大小
}pthread_attr_t;

线程属性结构体成员参考博客:多线程属性pthread_attr详解

 作为结构体第一个列出来的成员, detachstate 需要补充的是(其中提到的函数会一一分析):

  一个进程创建后,会首先生成一个缺省的线程,通常称这个线程为主线程(或称控制线程),C/C++程序中,主线程就是通过main函数进入的线程,由主线程调用pthread_create()创建的线程称为子线程,子线程也可以有自己的入口函数,该函数由用户在创建的时候指定。每个线程都有自己的线程ID,可以通过pthread_self()函数获取。最常见的线程模型中,除主线程较为特殊之外,其他线程一旦被创建,相互之间就是对等关系,不存在隐含的层次关系。每个进程可创建的最大线程数由具体实现决定.无论在windows中还是Posix中,主线程和子线程的默认关系是:无论子线程执行完毕与否,一旦主线程执行完毕退出,所有子线程执行都会终止。这时整个进程结束或僵死,部分线程保持一种终止执行但还未销毁的状态,而进程必须在其所有线程销毁后销毁,这时进程处于僵死状态。线程函数执行完毕退出,或以其他非常方式终止,线程进入终止态,但是为线程分配的系统资
源不一定释放,可能在系统重启之前,一直都不能释放,终止态的线程,仍旧作为一个线程实体存在于操作系统中,什么时候销毁,取决于线程属性。在这种情况下,主线程和子线程通常定义以下两种关系:

  1. 可会合(joinable):这种关系下,主线程需要明确执行等待操作,在子线程结束后,主线程的等待操作执行完毕,子线程和主线程会合,这时主线程继续执行等待操作之后的下一步操作。主线程必须会合可会合的子线程。在主线程的线程函数内部调用子线程对象的wait函数实现,即使子线程能够在主线程之前执行完毕,进入终止态,也必须执行会合操作,否则,系统永远不会主动销毁线程,分配给该线程的系统资源也永远不会释放。
  1. 相分离(detached):表示子线程无需和主线程会合,也就是相分离的,这种情况下,子线程一旦进入终止状态,这种方式常用在线程数较多的情况下,有时让主线程逐个等待子线程结束,或者让主线程安排每个子线程结束的等待顺序,是很困难或不可能的,所以在并发子线程较多的情况下,这种方式也会经常使用。线程的分离状态决定一个线程以什么样的方式来终止自己,在默认的情况下,线程是非分离状态的,这种情况下,原有的线程等待创建的线程结束,只有当pthread_join函数返回时,创建的线程才算终止,释放自己占用的系统资源,而分离线程没有被其他的线程所等待,自己运行结束了,线程也就终止了,马上释放系统资源。

  对于这些属性,我们需要设定的是线程的分离状态,如果有必要也需要修改每个线程的栈大小。每个线程创建后默认是joinable状态,该状态需要主线程调用 pthread_join 等待它退出,否则子线程在结束时,内存资源不能得到释放造成内存泄漏。所以我们创建线程时一般会将线程设置为分离状态,具体有两种方法:

  1. 线程里面调用 pthread_detach(pthread_self()) 这个方法最简单
  2. 在创建线程的属性设置里设置PTHREAD_CREATE_DETACHED属性
pthread_attr_init()
#include <pthread.h>

int pthread_attr_init(pthread_attr_t *attr);   //初始化线程对象的属性;
int pthread_attr_destroy(pthread_attr_t *attr);   //销毁一个目标结构,并且使它在重新初始化之前不能重新使用。

参数只有一个,那就是定义的线程属性的结构体,成功返回0,失败返回非零的错误数字;

pthread_attr_setstacksize()
#include <pthread.h>

int pthread_attr_setstacksize(pthread_attr_t *attr,size_t stacksize); 

第一个参数为线程属性的结构体,第二个是线程栈的大小;成功返回0,失败返回非零;

pthread_attr_setdetachstate()
#include <pthread.h>

int pthread_attr_setstacksize(pthread_attr_t *attr, size_t stacksize);

设置该线程是分离还是会合,取 PTHREAD_CREATE_DETACHED 时,线程执行完后无需他人等待,自动销毁,释放内存空间;
PTHREAD_CREATE_JOINABLE 时,主线程就要调用 pthread_join() 函数来等待子线程退出,因为我们对他退出时的状态感兴趣,主线程会阻塞在此知道子线程结束,函数返回,销毁该线程,释放空间。
成功返回0,失败返回非零;

pthread_create();
#include <stdio.h>

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

第一个参数为指向线程标识符的指针,用来返回该线程的ID;
第二个参数用来设置线程属性;
第三个参数是线程运行函数的起始地址,即需要该函数执行的任务;
最后一个参数是运行函数的参数。

三、锁

  如果一个资源会被不同的线程访问修改,那么我们把这个资源叫做临界资源,那么对于该资源访问修改相关的代码就叫做临界区。那么怎么解决多个线程之间共享同一个共享资源,是多线程编程需要考虑的一个问题。

假设我们其中一个线程A正在做某事,需频繁的用到进程中的某个变量的值,而这时,线程B也用到了该值,并且在改变该变量的值,那么线程A在每次获取这个临界资源时的值都不一样,会导致线程A的工作无法正常执行!
此时,我们就需要用到锁。

阻塞锁

多个线程同时调用同一个方法的时候,所有线程都被排队处理了。让线程进入阻塞状态进行等待,当获得相应的信号(唤醒,时间),才可以进入线程的准备就绪状态,准备就绪状态的所有线程,通过竞争,进入运行状态。

非阻塞锁

多个线程同时调用一个方法的时候,当某一个线程最先获取到锁,这时其他线程判断没拿到锁,这时就直接返回,只有当最先获取到锁的线程释放,其他线程才能进来,在它释放之前其它线程都会获取失败。

自旋锁和互斥锁

一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。获取锁的线程一直处于活跃状态,由于一直调用while循环,但是并没有执行任何有效的任务,使用这种锁会造成busy-waiting。
互斥锁也是为了保护共享资源的同步,在任何时刻,最多只能有一个保持者,也就说,在任何时刻最多只能有一个执行单元获得锁。但是两者在调度机制上略有不同。对于互斥锁,如果资源已经被占用,资源申请者只能进入睡眠状态。但是自旋锁不会引起调用者睡眠,如果自旋锁已经被别的执行单元保持,调用者就一直循环在那里看是否该自旋锁的保持者已经释放了锁。

死锁

如果多个线程要调用多个对象,则在上锁的时候可能会出现“死锁”。举个例子: A、B两个线程会同时使用到两个共享变量m和n,同时每个变量都有自己相应的锁M和N。 这时A线程首先拿到M锁访问m,接下来他需要拿N锁来访问变量n; 而如果此时B线程拿着N锁等待着M锁的话,就造成了线程“死锁”。
在这里插入图片描述

死锁产生的4个必要条件
1、互斥:某种资源一次只允许一个进程访问,即该资源一旦分配给某个进程,其他进程就不能再访问,直到该进程访问结
束。
2、占有且等待:一个进程本身占有资源(一种或多种),同时还有资源未得到满足,正在等待其他进程释放该资源。
3、不可抢占:别人已经占有了某项资源,你不能因为自己也需要该资源,就去把别人的资源抢过来。
4、循环等待:存在一个进程链,使得每个进程都占有下一个进程所需的至少一种资源。
当以上四个条件均满足,必然会造成死锁,发生死锁的进程无法进行下去,它们所持有的资源也无法释放。这样会导致CPU的吞吐量下降。所以死锁情况是会浪费系统资源和影响计算机的使用性能的。那么,解决死锁问题就是相当有必要的了。
产生死锁需要四个条件,那么,只要这四个条件中至少有一个条件得不到满足,就不可能发生死锁了。由于互斥条件是非共享资源所必须的,不仅不能改变,还应加以保证,所以,主要是破坏产生死锁的其他三个条件。

a、破坏“占有且等待”条件
方法1:所有的进程在开始运行之前,必须一次性地申请其在整个运行过程中所需要的全部资源。
优点:简单易实施且安全。 缺点:因为某项资源不满足,进程无法启动,而其他已经满足了的资源也不会得到利用,严重降低了资源的利用率,造成
资源浪费。使进程经常发生饥饿现象。
方法2:该方法是对第一种方法的改进,允许进程只获得运行初期需要的资源,便开始运行,在运行过程中逐步释放掉分配到的已经使用完毕的资源,然后再去请求新的资源。这样的话,资源的利用率会得到提高,也会减少进程的饥饿问题。
b、破坏“不可抢占”条件
当一个已经持有了一些资源的进程在提出新的资源请求没有得到满足时,它必须释放已经保持的所有资源,待以后需要使用的时候再重新申请。这就意味着进程已占有的资源会被短暂地释放或者说是被抢占了。该种方法实现起来比较复杂,且代价也比较大。释放已经保持的资源很有可能会导致进程之前的工作实效等,反复的申请和释放资源会导致进程的执行被无限的推迟,这不仅会延长进程的周转周期,还会影响系统的吞吐量。
c、破坏“循环等待”条件
可以通过定义资源类型的线性顺序来预防,可将每个资源编号,当一个进程占有编号为i的资源时,那么它下一次申请资源只
能申请编号大于i的资源。

发布了8 篇原创文章 · 获赞 11 · 访问量 302

猜你喜欢

转载自blog.csdn.net/weixin_45121946/article/details/104605316