Linux多线程
一、线程的概念
线程是进程中的一条执行流,线程在进程的地址空间内运行但是因为Linux下执行流是通过PCB实现的,一个进程中可以有多个执行流,也就是一个进程中可以有多个PCB,并且这些PCB共享了很多程序运行所需资源,因此Linux下的PCB就起了一个新名字—轻量级进程。
二、进程和线程的区别
进程是操作系统进行资源分配的基本单元。
线程是操作系统进行CPU调度的基本单元。
线程是进程中的一条执行流,是CPU调度的基本单元,因为Linux下执行流是通过PCB实现的,因此Linux下的线程实际上就是一个PCB,一个进程中可以有多个执行流PCB,这些PCB共享进程的大部分资源,因此也被称作轻量级进程。也有人说Linux下没有真正的线程,线程实际上就是一个轻量级进程。
不同学习阶段的理解:
以前的进程是只有一个线程/轻量级进程的进程,而现在所说的进程是可以具有多个线程/轻量级进程的进程。
在以前学习进程的时候进程就是一个PCB,就是一个task_struct结构体,有很多描述信息实现操作系统对程序运行的管理和调度。
现在学习进程的时候,发现线程就是进程中的一条执行流,因而在Linux下的程序调度是通过PCB来完成的,所以理解PCB就是Linux下的执行流,反推到Linux下的PCB就是一个线程,只不过Linux下的线程通常被称为轻量级进程。
三、线程之间的独有与共享
共享:
- 虚拟地址空间。
- 信号处理方式。
- IO信息
- 工作路径
独有:
- 独有一个栈。(共享一个虚拟地址空间,如果栈不独有的话就会造成栈混乱)
- 寄存器(上下文数据)
- errno(不同的线程可能调用同一个系统调用接口,如果一个成功一个失败,返回值就会混乱)
- 信号屏蔽字(信号阻塞集合)
- 线程ID
四、多进程与多线程在多任务处理中的优缺点
多线程:
- 优点:
- 线程间通信非常灵活(通过进程间通信方式,全局变量,函数传参等)
- 线程的创建于销毁的成本更低。(线程资源大多共享)
- 线程间的切换调度成本稍低。
多进程:
- 优点:
- 独立性高,稳定性强。
**共同优点:**多任务使用多执行流处理的优点
-
CPU密集型操作:程序中几乎都是CPU数据运算。
多核CPU:更加充分利用CPU资源。但执行流不是越多越好,多了反而增加切换调度成本。
-
IO密集型程序:程序中几乎都是IO操作。
IO操作:等待IO就绪,拷贝数据。
五、线程控制
在Linux系统中没有直接提供线程的操作接口,因此大佬们就在上层封装实现了一套线程库。实现在用户态创建一个线程,但是Linux程序下,程序调度只能通过PCB完成,因此在内核创建了一个PCB。这时候我们上层创建的线程叫做用户态线程,而把内核的PCB叫做轻量级进程。
**pthread_t tid:**实际上 pthread_t 在底层是无符号长整形,保存的是线程相对独立空间的首地址,这块空间开在共享区。tid实际上就是线程的操作句柄。
用户空间:通过线程库pthread,描述线程tcb,组织线程,完成的是用户空间的线程管理功能。
内核空间:创建一个tcp就会一对一对应的在内核中创建一个LWP(轻量级进程),完成的是线程的执行功能。
1.线程创建
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine) (void *), void *arg);
- pthread_t *thread:用于获取上层线程ID。
- const pthread_attr_t *attr:用于设置线程属性,通常置空。
- void *(*start_routine) (void *):线程入口函数。
- void *arg:传递给线程入口函数的参数。
- 返回值:成功返回0,失败返回错误码。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <string.h>
#include <unistd.h>
void* thread_entry(void* arg)
{
while(1)
{
printf("i an a nuw process %s \n",(char*)arg);
sleep(1);
}
return NULL;
}
int main(int argc, char *argv[])
{
pthread_t tid;
char buf[32] = "hello world";
int ret = pthread_create(&tid, NULL, thread_entry, (void*)buf);
if(ret != 0)
{
printf("thread creat error:%s",strerror(ret));
return -1;
}
while(1)
{
printf("i am main process %s \n",buf);
sleep(1);
}
return 0;
}
2.线程终止
一个线程的入口函数运行完毕了,则这个线程就会退出。
如果需要只终止某个线程而不终止整个进程,可以有三种方法 :
-
从线程入口函数return。(main函数中的return退出的是整个进程)
-
线程可以调用pthread_ exit终止自己 。
void pthread_exit(void *retval);
函数没有返回值,但是参数retval这是线程的退出返回值。
如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
-
一个线程可以调用pthread_ cancel,可以在任意位置退出指定线程。
int pthread_cancel(pthread_t thread);
如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED (-1)。
3.线程等待
等待一个指定的线程退出,获取这个线程的退出返回值,释放资源。
默认情况下,线程退出,为了保存自己的退出返回值,因此线程所占用的资源在线程退出之后也不会完全被释放,需要被其他线程等待。
int pthread_join(pthread_t tid, void **retval);
- tid:表示要等待哪个线程退出。
- retval:是一个void*空间的地址,用于接收线程的返回值。
- 返回值:成功返回0,失败返回错误码。
- 这是一个阻塞接口,直到有线程退出才会继续运行。
但是,当我们不关心一个线程的返回值的时候,又不需要等待线程线程退出才往下运行,这时候等待会导致性能降低,在这种场景下,等待就不合适了,但是不等待就会造成资源泄露,基于这个需求就有了线程分离。
4.线程分离
在设计线程的时候,线程有很多属性,其中一个属性就叫做分离属性,分离默认属性值为joinable,表示线程退出之后不会自动释放资源,需要被等待。
如果将线程的分离属性设置为detach,这时候线程退出后就不需要被等待,而是直接释放资源。一旦设置了分离属性,退出后就会自动释放资源,则等待将毫无意义,所以设置了分离属性的线程是不能被等待的。
int pthread_detach(pthread_t tid);
当一个线程既不关系返回值,也不想等待的时候,这种线程就适合被分离。
五、线程安全
1.概念:多线程之间对同一个临界资源的访问操作是安全的。多个 线程同时修改同一个临界资源可能会造成数据二义。
2.实现:如何实线程操作是安全的
- 互斥:保证执行流在同一时间对临界资源的唯一访问。(互斥锁,也叫互斥量)
- 同步:在保证数据安全的情况下(一般使用加锁操作),使不同执行流对临界资源的访问有一定的顺序性,实现对资源获取的合理操作。(条件变量,信号量)
同步和互斥协同,可以使多线程高效完成某些事物。
3.互斥锁:本质就是一个0 / 1 的计数器,进行一个状态的描述,用于标记资源的访问状态。0表示不可访问,1表示可以访问。
操作:
-
加锁:将状态置为不可访问状态
-
解锁:将状态置为可访问状态
-
一个执行流在访问资源前进行加锁操作,如果不能加锁则阻塞;在访问资源完毕之后解锁。
互斥锁实现互斥,本质上自己也是一个临界资源(同一个资源所有线程在访问的时候 是加同一把锁)。
因此互斥锁必须必须先保证自己是安全的:所以互斥锁的操作是一个原子操作。
实现:
-
定义互斥锁变量
pthread_mutex_t mutex
-
初始化互斥锁
int pthread_mutex_init(pthread_mutex_t *mutex, const pthread_mutexattr_t *attr);
参数:
mutex:要初始化的互斥量。
attr:通常置NULL。 -
加锁
int pthread_mutex_lock(pthread_mutex_t *mutex); 阻塞接口,不能加锁就阻塞。
int pthread_mutex_trylock(pthread_mutex_t *mutex); 非阻塞接口,不能加锁就报错(EBUSY)返回。
-
解锁
int pthread_mutex_unlock(pthread_mutex_t *mutex);
-
释放销毁锁
int pthread_mutex_destroy(pthread_mutex_t *mutex);
注意
- 在使用锁的过程中,加锁后,在任何可能退出的位置都要解锁。
- 锁只能保证安全操作,无法保证操作合理。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
int tickets = 100;
pthread_mutex_t mutex;
void* scalpers(void* arg)
{
pthread_t tid = pthread_self();
while(1)
{
pthread_mutex_lock(&mutex);
if(tickets > 0)
{
printf("%p get ticket %d\n", tid, tickets);
tickets--;
pthread_mutex_unlock(&mutex);
}
else
{
pthread_mutex_unlock(&mutex);
pthread_exit(NULL);
}
}
return 0;
}
int main(int argc, char *argv[])
{
pthread_mutex_init(&mutex, NULL);
pthread_t tid[4];
for(int i = 0; i < 4; i++)
{
int ret = pthread_create(&tid[i], NULL, scalpers, NULL);
if(ret != 0)
{
printf("pthread_create error\n");
return -1;
}
}
for(int i = 0; i < 4; i++)
{
pthread_join(tid[i], NULL);
}
pthread_mutex_destroy(&mutex);
return 0;
}
4.死锁
概念:程序流程无法继续前进,卡死的情况叫做死锁。由于锁资源的争抢不当所导致的。
死锁产生的四个必要条件。
- 互斥条件:一个资源每次只能被一个执行流使用。(一个线程对资源加锁了,其他线程就不能对这个线程加锁了)
- 不可剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺。(一个线程对资源加锁了,只能由这个线程来解锁,不能由其他进程解锁)
- 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。
- 环路等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
产生原因:
- 加解锁顺序不一致。
- 阻塞加锁。
死锁的预防: 编写代码过程中,破坏死锁产生的必要条件(加解锁顺序保持一致,使用非阻塞接口加锁)
**死锁的避免:**银行家算法。
- 定义系统运行状态:安全、非安全
- 定义表:所有资源表、已分配资源表、资源请求表
- 思想:查看资源请求表,哪个线程要请求哪个锁,根据前两张表进行判断,这个锁分配给线程是否有可能造成环路等待,如果有则不予分配。
5.同步的实现
通过一些条件判断,保证执行流对于资源获取的合理。
条件变量: 提供了一个PCB等待队列,以及使线程阻塞和唤醒进程的两个接口。
实现:
-
定义条件变量
pthread_cond_t cond
-
初始化条件变量
int pthread_cond_init(pthread_cond_t *cond,const pthread_condattr_t *attr);
参数:
cond:要初始化的条件变量
attr:NULL -
使线程阻塞
int pthread_cond_wait(pthread_cond_t *cond,pthread_mutex_t *mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量这个接口涉及三个操作:解锁,阻塞,被唤醒后加锁;
并且解锁和陷入阻塞是与原子操作,一步完成不会被打断。
-
唤醒阻塞的线程
int pthread_cond_signal(pthread_cond_t *cond); 将cond的PCB队列中的线程至少唤醒一个。int pthread_cond_broadcast(pthread_cond_t *cond);
将cond的PCB队列中的线程全部唤醒。
-
释放销毁条件变量
int pthread_cond_destroy(pthread_cond_t *cond )
条件变量使用注意事项:
- 是否满足条件的判断,应该使用循环操作。
- 多种角色线程,等待应该分开等待,分开唤醒,防止唤醒角色错误。多种角色定义多个条件变量。
- 信号量值提供了使线程阻塞,和唤醒线程的接口,至于什么时候阻塞,什么时候唤醒,条件变量本身并不关心,全部由用户自己控制。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <pthread.h>
int flag = 0;
pthread_cond_t cond;
pthread_mutex_t mutex;
void* printa(void* arg)
{
while(1)
{
pthread_mutex_lock(&mutex);
while(flag == 0)
{
pthread_cond_wait(&cond, &mutex);
}
flag = 0;
printf("我是线程A\n");
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond);
}
}
void* printb(void* arg)
{
while(1)
{
pthread_mutex_lock(&mutex);
while(flag == 1)
{
pthread_cond_wait(&cond, &mutex);
}
flag = 1;
printf("我是线程B\n");
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond);
}
}
int main(int argc, char *argv[])
{
pthread_mutex_init(&mutex,NULL);
pthread_cond_init(&cond,NULL);
pthread_t tid1,tid2;
pthread_create(&tid1, NULL, printa, NULL);
pthread_create(&tid2, NULL, printb, NULL);
pthread_join(tid1, NULL);
pthread_join(tid2, NULL);
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
return 0;
}
信号量: 本质就是一个计数器,描述资源的有效个数,用于实现进程或线程之间的互斥与同步。
操作:
- P操作:计数-1,判断计数是否大于等于0,成立则返回,否则阻塞。
- V操作:计数+1,唤醒一个阻塞的进程或线程。
同步的实现:通过计数器对资源数量进行计数,获取资源之前进行P操作,产生资源之后进行V操作。通过这种方式实现对资源的合理获取。
互斥的实现:计数器初始值为1(资源只有一个),访问资源之前进行P操作,访问完毕进行V操作,实现类似于加锁和解锁的操作。
接口:
-
定义
sem_t sem;
-
初始化
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
pshared:0表示线程间共享,非零表示进程间共享;
value:信号量初始值; -
功能:等待信号量,会将信号量的值减1(P操作)
int sem_wait(sem_t *sem); -
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1(V操作)
int sem_post(sem_t *sem); -
销毁信号量
int sem_destroy(sem_t *sem) ;
条件变量与信号量实现同步上的区别:
- 本质上的不同,信号量十个计数器,条件变量没有计数器,因此条件变量的资源访问合理性需要用户自己定义,但是信号量可以通过自身计数完成。
- 条件变量需要搭配互斥锁一起使用,而信号量不用。
线程池
线程池适用于有大量任务需要处理的场景,使用多执行流可以提高效率。若一个任务到来就创建一个线程进行处理,处理完之后销毁线程有很大的缺陷。
- 一个任务的处理成本(线程创建时间+任务处理时间+线程销毁时间=总耗时),若任务处理时间较短,则大量时间被线程的创建与销毁消耗了。
- 若线程无限制创建,则在峰值压力下,会有资源耗尽系统崩溃的风险。保证服务器中处理任务的线程的个数是稳定的。
线程池其实就是一堆创建好的线程和任务队列,有任务来了就抛入线程池,分配一个线程进行处理,节省了任务处理过程中线程的创建与销毁的时间成本。而且线程池中的线程与任务节点数量都有最大限制,避免资源耗尽。
线程池的作用
- 减少资源的开销:减少了每次创建线程和销毁线程的开销。
- 提高响应速度:每次当任务到来的时候,由于线程已经被创建完毕,可以直接从阻塞队列中读取任务进行处理,因此提高了响应的速度。
- 提高线程的可管理性:线程是一种稀缺资源,若不加以限制,不仅会占用大量资源,而且会影响系统的稳定性。因此,线程池可以对线程的创建与停止、线程数量等等因素加以控制,使得线程在一种可控的范围内运行,不仅能保证系统稳定运行,而且方便性能调优。
线程池的实现原理
线程池一般由两种角色构成:多个工作线程和一个阻塞任务队列。
- 工作线程:是已经被创建好的一组线程,不断地向阻塞队列中取出任务进行处理。
- 阻塞队列:用于存储线程需要处理的任务,使生产任务的线程与处理任务的线程不直接通信,而是通过这个阻塞队列,达到解耦合的作用。
threadPool.hpp
#ifndef __THREADPOOL_H__
#define __THREADPOOL_H__
#include <iostream>
#include <queue>
#include <pthread.h>
#include <string>
#include <unistd.h>
#include <math.h>
#define NUM 5
class Task
{
public:
int base;
public:
Task()
{}
Task(int _b)
:base(_b)
{}
void run()
{
std::cout<<"Task run....done"<< base << "的平方 = "<< pow(base, 2) <<std::endl;
}
~Task()
{}
};
class threadPool
{
private:
std::queue<Task*>q;
int max_cap;
pthread_mutex_t mutex;
pthread_cond_t cond;//只能让消费者进行等待,生产者不能等,因为还要从外部获取数据
public:
threadPool(int _max = NUM)
:max_cap(_max)
{}
static void* _enter(void* arg)
{
threadPool *tp = (threadPool*)arg;
while(1)
{
pthread_mutex_lock(&(tp->mutex));
while(tp->q.empty())
{
pthread_cond_wait(&(tp->cond), &(tp->mutex));
}
Task t;
tp->Get(t);
pthread_mutex_unlock(&(tp->mutex));
t.run();
}
}
void threadPoolInit()
{
pthread_mutex_init(&mutex, nullptr);
pthread_cond_init(&cond, nullptr);
pthread_t tid;
for(int i = 0; i < max_cap; i++)
{
pthread_create(&tid, nullptr, _enter, this);
/*关于_enter 入口函数:
1. 由于_enter函数是类的成员函数,会有一个隐藏的this指针,所以_enter函数实际上
有两个参数,this和arg,但是pthread_creat要求传入的函数只有一个参数,所以会
报错,所以_enter函数要定义成static函数,这个函数只属于类,并不属于某个对象。
2. 但是这样的话,static函数就不能调用类内部成员变量和函数,所以第四个参数要传给
arg的值就是this指针,将this传入arg,在_enter中通过arg访问类内部成员。
*/
}
}
void Put(Task &t)
{
pthread_mutex_lock(&mutex);
q.push(&t);
pthread_mutex_unlock(&mutex);
pthread_cond_signal(&cond);
//pthread_cond_broadcast(&cond);
/*为什么不使用broadcast这个接口:
这个接口是唤醒阻塞队列中的所有线程,会造成惊群现象。而signal会唤醒一个。
*/
}
void Get(Task &t)
{
Task* T = q.front();
q.pop();
t = *T;
}
~threadPool()
{
pthread_mutex_destroy(&mutex);
pthread_cond_destroy(&cond);
}
};
main.cpp
#include "threadpool.hpp"
int main()
{
threadPool *tp = new threadPool();
tp->threadPoolInit();
while(1)
{
int x = rand()%10 + 1;
Task task(x);
tp->Put(task);
sleep(1);
}
return 0;
}