Linux之多线程

文章目录

  • 一、线程概念
    • 1.多进程与多线程进行多任务处理的优缺点分析
    • 2.多进程与多线程进行多任务处理的优势在哪?
    • 3.多线程与多进程
  • 二、线程控制
    • 1.线程创建
    • 2.线程终止
    • 3.线程等待
    • 4.线程分离
  • 三、线程安全
    • 1.线程安全的概念和实现
    • 2.互斥的实现(互斥锁)
    • 3.同步的实现(条件变量)
    • 4.生产者与消费者模型
    • 5.信号量(POSIX)
    • 6.读者写者模型-----读写锁
    • 7.线程池
    • 8.线程安全的单例模式

一、线程概念

Linux之多线程_第1张图片

  • 在传统操作系统中,pcb就是进程,线程有个tcp。但在Linux下,因为线程是通过进程pcb描述实现的,因为Linux下的pcb实际是一个线程,并且因为这些线程共用同一个虚拟地址空间,因此也把Linux下的线程称为轻量级进程,相较于传统的pcb更加的轻量化。
  • pcb是轻量级进程(线程),进程是一个线程组。
  • Linux下,线程是CPU调度的基本单位,而进程是资源分配的基本单位。(一块虚拟地址空间是分配给一个进程的,一个进程是由多个线程组成的。)

1.多进程与多线程进行多任务处理的优缺点分析

(1)多线程优点:

  • 因为共用同一块虚拟地址空间,因此通信更加灵活。(线程间通信方式:全局变量、函数传参、管道、消息队列、共享内存、信号量)
  • 线程的创建与销毁成本更低。
  • 线程的切换调度成本更低。(进程间切换时不仅仅要切换进程pcb信息,还有页表之类的。)

(2)多线程缺点:

  • 线程间缺乏访问控制,某些系统调用以及异常是针对整个进程产生的效果。(如exit退出一个进程)
  • 性能损失,一个很少被外部事件阻塞的计算密集型线程往往无法与其它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
  • 健壮性降低,在一个多线程程序里,因为时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的。线程之间是缺乏保护的。
  • 编程难度提高,编写与调试一个多线程程序比单线程程序困难得多。

2.多进程与多线程进行多任务处理的优势在哪?

  • IO密集型程序(在程序大量进行IO操作,对CPU的消耗较少)(IO操作有两个阶段:IO等待,等待就绪之后进行数据拷贝。)此时多线程和多进程处理时可以进行并行IO等待。
  • CPU密集型程序(在程序中不断进行数据运算。)此时多线程和多进程进行处理时效率更高。

3.多线程与多进程

  • 线程和进程不是越多越好,线程过多会造成大量的CPU调度切换,如果调度切换所耗费的时间大于对程序提高的效率,就不好了。线程创建最好数量是CPU的核心数加一。(多出一个线程是当有一个线程IO阻塞时顶上使用。)
  • 多进程可以独立运行是因为每一个进程都有自己的虚拟地址空间,而多线程是共用同一个虚拟地址空间,为了使其独立运行,线程会有自己独有的数据。
  • 线程的独有和共享
    ① 独有:栈(不会造成自己调用栈紊乱),寄存器(上下文数据和程序计数器),优先级,errno,信号屏蔽字。
    ② 共享:虚拟地址空间(代码段和数据段),文件描述符表(所有线程可以访问一个文件),信号处理方式(信号处理事件针对每一个进程),当前工作路径(一个进程默认的工作路径是在哪个路径下运行,则当前路径为默认的工作路径),用户ID、组ID。

二、线程控制

  • 操作系统并没有给用户直接提供创建一个线程的接口,意味着在用户态没办法实现创建一个线程,因此后来人们封装了一套线程库,用于线程控制。把创建的线程叫做用户线程,是用户态的线程,使用库函数创建的。

1.线程创建

  • void pthread_create(pthread_t* tid, pthread_attr_t* attr, void *(*start_routine)(void *), void *arg);
  • 第二个参数*attr一般置为NULL,第三个参数是一个线程入口函数,第四个参数arg为传递给线程入口函数(线程)的参数。
  • 返回值:成功返回0,失败返回一个错误编号。
  • 库函数中的数据栈区在共享区里,每个线程在进程的虚拟地址空间里都有一个独立的线程地址空间(包含用户态线程的描述信息、栈等),而线程创建中的第一个参数tid就是线程地址空间在进程虚拟地址空间中的首地址。
  • tid:线程地址空间首地址。pcb->pid:轻量级进程ID。pcb->tgid:进程ID(默认为主线程的pid)。
  • ps -L查看轻量级进程信息。
  • 进程的pcb是主线程的pcb,进程的信息是主线程的信息。

2.线程终止

① 线程将自己的入口函数运行完毕后return退出,(main中return退出的是进程)
② 线程可以调用pthread_exit终止自己。
③ 一个线程可以调用pthread_cancel终止同一个进程中的另一个线程。

函数原型 含义
void pthread_exit(void* retval); 终止线程(调用者自身)
int pthread_cancel(pthread_t tid); 取消一个同一个进程中的指定的执行中的线程。
  • 违规操作:在主线程中使用pthread_exit(NULL));接口退出,此时主线程变为僵尸线程,其余线程依旧运行,进程变为僵尸状态,因为查看的进程信息其实是主线程信息。
  • 线程退出也不会完全释放资源,需要被其它线程等待。

3.线程等待

函数原型 含义
int pthread_join(pthread_t thread, void** retval); 等待线程结束
  • 参数thread:线程ID;参数retval:指向一个指针,后者指向线程的返回值。
  • 一个线程创建出来,默认在退出时不会释放所有资源的,这是因为线程有一个属性----joinable。处于joinable状态的线程,退出后不会自动释放资源,需要被等待。

4.线程分离

  • 将线程的属性从joinable设置为detach。表示一个线程被分离,处于detach属性的线程退出后,则会自动释放所有资源,这意味着被分离的线程没必要被等待。
  • joinable和分离是冲突的,一个线程不能既是joinable的又是detach的。
函数原型 含义
int pthread_detach(pthread_t tid); 表示分离指定的线程。
pthread_t pthread_self(void); 表示获取调用线程的tid。
pthread_detach(pthread__self()) 表示线程分离自己。

三、线程安全

1.线程安全的概念和实现

(1)概念:多个执行流(线程,一个线程就是一个执行流)对同一个临界资源进行争抢访问,但是不会造成数据的二义性。
(2)实现:

  • 同步:通过对条件判断实现对临界资源访问的时序合理。(不能访问则等待,能够访问则唤醒。)
  • 互斥:同一时间只能有一个执行流能够访问临界资源,实现数据操作安全。
  • 互斥的实现:互斥锁/信号量。
  • 同步的实现:条件变量/信号量。

2.互斥的实现(互斥锁)

(1)互斥锁:

函数原型 含义
pthread_mutex_t 互斥锁变量类型
pthread_mutex_init(pthread_mutex_t* mutex, pthread_mutex_t* attr); 互斥锁初始化(第一个参数为互斥锁变量,第二个参数一般置空)
pthread_mutex_lock(pthread_mutex_t* mutex); 互斥锁加锁
pthread_mutex_unlock(pthread_mutex_t* mutex); 互斥锁解锁
pthread_mutex_destroy(pthread_mutex_t* mutex); 互斥锁销毁

(2)死锁:

  • 什么事死锁?如何产生的?----- 多个线程对锁资源进行争抢访问,但是因为推进顺序不当,导致互相等待,造成程序流程无法继续推进,这就是死锁的产生。
  • 在死锁产生的过程中有四个必要条件:
    ① 互斥条件:一个资源每次只能被一个执行流使用。同一时间,锁只有一个线程能够获取。
    ② 不可剥夺条件:一个执行流已获得的资源,在未使用完之前,不能被强行剥夺。我加锁只有我能解锁,别人不能解锁。
    ③ 请求与保持条件:一个执行流因为请求资源而阻塞时,对已获得的资源保持不放。拿着A锁请求B,若请求不到,则一直保持A锁不释放。
    ④ 循环等待条件:若干个执行流之间形成一种头尾相接的循环等待资源的关系。

(3)死锁的预防

  • 对互斥锁来说破坏请求与保持条件和循环等待条件。
  • 避免锁未释放的场景。
  • 资源一次性分配。
  • 加锁顺序一致。

(4)死锁的避免

  • 银行家算法。
  • 死锁检测算法。

3.同步的实现(条件变量)

  • 条件变量提供了让一个线程等待与唤醒的功能。
  • 条件变量并没有条件判断的功能,也就是说不具备判断什么时候线程该等待或唤醒的功能,因此条件判断需要用户自身完成。
函数原型 含义
pthread_cond_t 条件变量类型
pthread_cond_init(pthread_cond_t* cond, pthread_cond_t* attr); 条件变量初始化(第一个参数为条件变量,第二个参数为属性一般置空。)
pthread_cond_wait(pthread_cond_t* cond, pthread_mutex_t* mutex); 访问条件不满足时,让一个线程一直阻塞。
pthread_cond_timedwait(pthread_cond_t* cond, pthread_mutex_t* mutex, struct timespec ); 访问条件不满足时,限时阻塞等待。等待指定时间内都没有唤醒则自动醒来。
pthread_cond_signal(pthread_cond_t* cond); 通过条件变量唤醒等待队列上等待的线程。
pthread_cond_broadcast(pthread_cond_t* cond); 唤醒所有等待的线程。
pthread_cond_destroy(pthread_cond_t* cond); 销毁条件变量。

4.生产者与消费者模型

  • 生产者与消费者模型就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。
  • 生产者和消费者之间有一个缓冲区(一个队列)(支持忙闲不均,支持并发,当有多个生产者和多个消费者时,在线程安全时,可以多个消费者和多个生产者并发,解耦合(封装一种解耦合方式,尽量减少生产者和消费者的关联))
  • 应用场景:有线程不断的生产数据,有线程不断的处理数据。
  • 数据的生产与数据的处理,放在同一个线程中完成,因为执行流只有一个,那么肯定是生产完一个之后处理一个,处理完一个之后再生产一个。这样的话依赖关系太强。如果处理比较慢的话,生产速度也会下降。
  • 因此将生产数据与处理数据放到不同的执行流中完成,中间增加一个数据缓冲区,作为中间的数据缓冲场所。若这个中间的数据缓冲场所保证了线程安全,则可以有多个生产者线程和消费者线程。
    Linux之多线程_第2张图片

5.信号量(POSIX)

  • 实现线程/进程间的同步与互斥。
  • 本质:计数器 + 等待队列 + 等待与唤醒功能接口。
  • 信号量实现同步:通过自身的计数器进行资源计数,对临界资源访问之前先访问信号量,通过计数器判断是否有资源能够访问,若不能访问(计数 <= 0),则等待,并且计数-1(当信号量色值为负值时,表示当前有多少线程正在等待。);若可以访问(计数 > 0),则计数-1,直接访问。其它线程促使条件满足后,则判断,若计数>0,则计数+1,若计数<0,则唤醒一个等待队列上的线程,并且计数 +1.(计数大于0时表示还有多少资源,小于0时表示有多少线程在等待。)
  • 信号量实现互斥:只需要将计数维持在0/1之间就可以实现互斥。
  • 信号量实现同步和条件变量的区别:信号量通过自身计数判断实现同步,条件变量需要用户进行条件判断并且条件变量因为使用的是外部用户的条件判断,所以必须搭配互斥锁一起使用实现原子操作。而信号量自身计数的判断,它在内部就保证了自身计数的原子操作,因此不需要搭配互斥锁使用。
函数原型 接口
sem_t 定义信号量。
int sem_init(sen_t* sem, int pshared, usigned int value); 信号量的初始化(第一个参数是信号量变量;第二个参数pshared决定了当前的信号量用于进程间还是线程间,0是线程间,!0是进程间;第三个参数是信号量的初值,初始资源数量有多少计数就是多少。)
int sem_wait(sem_t* sem); 通过自身计数判断是否满足条件,不满足则直接一直阻塞进程/线程。条件判断加等待。若计数<=0,则减一后阻塞,若计数>0则减一后立即返回。
int sem_trywait(sem_t* sem); 通过字舍计数判断是否满足条件,不满足则立即报错返回,不阻塞、
int sem_timedwait(sem_t* sem, const strutct timespec* abs timeout); 不满足则等地啊指定时间,超时后报错返回ETIMEDOUT。
int sem_post(sem_t* sem); 通过信号链唤醒自己阻塞队列上的pcb,计数加1唤醒信号量等待队列上的线程。
int sem_destroy(sem_t* sem); 销毁信号量。

6.读者写者模型-----读写锁

  • 读者写者模型:有大量的读者线程只对临界资源进行读操作,但有少量写着线程对临界资源进行修改。
  • 读者可以同时读,写者不能写。写者写的时候其他写者不能写,其他读者不能读。(读共享,写互斥)
  • 读写锁:若要读,必须保证没人写,若要写,必须保证没人读和没人写。两个计数器(一个读者计数器,一个写者计数器)
  • 一个读者计数:若要写,保证读者计数为0。
  • 一个写者计数,若要读,保证写者计数为0,若要写,保证写着计数为0。
  • 若有大量读者一直读,则有可能造成写者饥饿,一直无法加写锁。因此读写锁具有:读者优先/写者优先的优先级。在加锁的时候,拒绝后续其它的异类加锁防止出现饥饿情况。
  • 无法加锁时进行等待,挂起等待/自旋等待,读写锁的等待通过自旋锁实现。
  • 自旋锁:循环一直判断条件是否满足,并不挂起线程。自旋锁不会放弃CPU,一直强占CPU,CPU不会轮转,不会调度。应用场景:明确条件的等待时间比较短的,否则会比较消耗CPU。

7.线程池

  • 线程池:线程的池子,有很多线程,但数量不会超过池子的限制。需要用到多执行流进行任务处理的时候,就从池子中取出一个线程去处理。
  • 应用场景:有大量的数据处理请求,需要多执行流并发/并行处理。若有大量的任务处理总耗时中的线程的创建与销毁占用了大量的比例,就意味着资源、性能浪费,在大量请求的峰值压力下,若每个请求都能创建一个线程,则有可能资源耗尽程序崩溃。
  • 线程池:提前创建一堆线程(最大数量限制),以及一个线程安全的任务队列,当大量的请求到来后,被添加到任务队列中,而线程池的线程不断从任务队列中获取任务进行处理即可。
  • 若是一个数据请求的到来伴随一个线程的创建去处理,则会产生一些风险以及一些不必要的消耗:
    ① 线程若不限制数量的创建,在峰值压力下,线程创建过多,资源耗尽,由程序崩溃的风险。
    ② 处理一个任务的时间:创建仙鹤草呢个时间t1 + 任务处理时间t2 + 线程销毁时间t3 = T,若t2 / T比例占据不够高,则表示大量的资源用创建与销毁成本上,因此线程池使用已经创建好的线程进行循环任务处理,就避免了大量线程的频繁创建与销毁的时间成本。

8.线程安全的单例模式

  • 什么是单例模式,请参考以下博客。C++之单例模式(饿汉模式、懒汉模式)
  • 线程安全的单例模式的实现----懒汉模式和饿汉模式。

(1)饿汉模式,所有资源加载或者对象的实例化都放在程序的初始化阶段,接下来在各个线程中直接使用。(优点:资源提前弄好了,运行时性能会比较高。整体运行比较流畅、但是所有资源加载都放在初始化阶段到内存中了,将当前不用的资源也加载到内存中,资源消耗比较大,初始化耗时比较长,一般用于程序后台的批处理程序)

class Test
{
public:
	int GetInstance()
	{
		returnn &data;
	}
private:
	static int data;
};
int test::data;

(2)懒汉模式,)资源并不在程序初始化阶段全部加载/初始化,而是等到使用的时候才去判断来加载/初始化。(优点:初始化比较快,若不释放的话运行阶段使用的时候也只需加载一次即可。)

class Test
{
public:
	int* GeInstance()
	{
		if(pdata == NULL)
		{
			pdata = (int*)malloc(4);
			return pdata;
		}
	}
private:
	static int* pdata;
};
int Test::pdata = nullptr;

你可能感兴趣的:(Linux)