Linux多线程编程
1、线程基本知识2、线程控制3、线程同步与互斥线程互斥线程同步条件变量生产者消费者模型POSIX信号量读者写者问题线程池单例模式
1、线程基本知识
线程概念
线程是在进程内部运行的一个执行分支(执行流),属于进程的一部分,粒度比进程更细和轻量化。
进程内部是指:线程在进程的地址空间内运行。
执行分支:CPU在调度的时候只看PCB,每一个PCB曾经被指派过指向的方法和数据,CPU可以直接调度。
线程间大部分数据是共享的,部分数据是私有的,如独立栈、上下文、PCB。
线程实现没有专门的PCB,使用进程来模拟的。
线程优点
1、创建一个新线程的代价要比创建一个新进程小得多
2、与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
3、线程占用的资源要比进程少很多
4、能充分利用多处理器的可并行数量
5、在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
6、计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
线程越多越好吗?
不一定如果线程太多会导致线程间被过度调度切换(有成本的)
7、I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
大部分时间是在等待I/O就绪的,线程是不是越多越好?不一定不过I/O允许多一些线程。
线程缺点
1、性能损失
一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型
线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的
同步和调度开销,而可用的资源不变。
2、健壮性降低
编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了
不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
3、缺乏访问控制
进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
4、编程难度提高
编写与调试一个多线程程序比单线程程序困难得多
2、线程控制
相关函数
功能:创建一个新的线程
原型
参数
thread:返回线程ID
attr:设置线程的属性,attr为NULL表示使用默认属性
start_routine:是个函数地址,线程启动后要执行的函数
arg:传给线程启动函数的参数
返回值:成功返回0;失败返回错误码
功能:等待线程结束
原型int pthread_join(pthread_t thread, void **value_ptr);
参数
thread:线程ID
value_ptr:它指向一个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码
功能:分离线程(可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离)
原型:int pthread_detach(pthread_t thread);
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
返回值:成功返回0;失败返回错误码
功能:取消一个执行中的线程
原型:int pthread_cancel(pthread_t thread);
参数
thread:线程ID
返回值:成功返回0;失败返回错误码
线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
1、从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
2、线程可以调用pthread_ exit终止自己。
3、一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。
pthread_exit函数
原型:void pthread_exit(void *value_ptr);
参数:value_ptr不要指向一个局部变量。(退出码)
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)
pthread_ self函数
功能:可以获得线程自身的ID:
原型:pthread_t pthread_self(void);
查看的线程id是pthread库中的id不是linux内核中的LWP,
pthread库的线程id是一个内存地址(虚拟地址)
来段代码感受一下:
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>
void *thread_run(void *args)
{
const char *id = (const char*)args;
for(int i = 0; i < 10; i++)
{
printf("我是%s线程, 我的线程ID是%d, 我的进程ID是%d\n", id, pthread_self(), getpid());
sleep(1);
}
//进程退出三种方式
//exit(123); 会同时终止调主进程
//pthread_exit((void*)123);
return (void*)123;
}
int main()
{
pthread_t tid;
//线程创建
pthread_create(&tid, NULL, thread_run, (void*)"thread 1");
for(int i = 0; i < 10; i++)
{
printf("我是main 线程, 我的ID是%d\n", getpid());
sleep(1);
//线程取消
if(i == 2)
pthread_cancel(tid);
}
printf("wait sub thread....\n");
sleep(1);
//指针变量本身就可以充当某种容器保存数据 void* 32/4 64/8 ->linux 下是64位
void* status = NULL; //指针变量本身就可以充当某种容器保存数据
// 等待线程
int ret = pthread_join(tid[0], &status);
//等待线程,若不等待则会像进程一样出现僵尸线程的问题
printf("ret: %d,status:%d \n ",ret, (long int)status);
return 0;
}
我们将上面代码运行起来,通过ps -aL可以查看线程和进程。
3、线程同步与互斥
<1>线程互斥
为什么要互斥
主要是因为多个线程并发的操作共享变量,会带来一些问题。我们通过一段抢票代码来观察一下。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
void *route(void *arg)
{
char *id = (char*)arg;
while ( 1 )
{
if ( ticket > 0 )
{
usleep(1000);
printf("%s sells ticket:%d\n", id, ticket);
ticket--; //这里看起来就是一行C、C++代码,但并非原子的,在汇编级别它是多行代码
}
else
{
break;
}
}
}
int main( void )
{
pthread_t tid[5];
for(int i = 0; i < 5; i++)
{
pthread_create(&tid[i], NULL, route, (void*)"thread 1");
}
for(int i = 0; i < 5; i++)
{
pthread_join(tid[i], NULL);
}
return 0;
}
运行结果:
我们看到这里票被抢到了负值,这在现实中是绝对不合理的。也就是出现了线程安全问题。
要解决以上问题,需要做到三点:
1、代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
2、如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
3、如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。
要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量。
互斥量mutex接口
初始化互斥量
方法1: 静态分配
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
方法2: 动态分配
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);
参数:
mutex:要初始化的互斥量
attr:NULL
销毁互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex);
注意:
使用PTHREAD_ MUTEX_ INITIALIZER初始化的互斥量不需要销毁
不要销毁一个已经加锁的互斥量
已经销毁的互斥量,要确保后面不会有线程再尝试加锁
互斥量加锁和解锁
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误码
调用pthread_mutex_ lock 时,可能会遇到以下情况:
1、互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
2、发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_mutex_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
改进上面的售票系统:
#include <iostream>
#include <cstdio>
#include <ctime>
#include <mutex>
#include <cstdlib>
#include <unistd.h>
#include <pthread.h>
//tickets是不是就是所谓的临界资源! tickets-- 是原子的吗?(是安全的吗?)
//为了让多个线程进行切换,线程什么时候可能切换(1. 时间片到了 2. 检测的时间点:从内核态返回用户态的时候)
//对临界区进行加锁
class Ticket{
private:
int tickets;
//pthread_mutex_t vs std::mutex
pthread_mutex_t mtx; //原生线程库,系统级别
// std::mutex mymtx; //C++ 语言级别
public:
Ticket():tickets(1000)
{
pthread_mutex_init(&mtx, nullptr);
}
bool GetTicket()
{
//static pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER; //也可以用这种方式来初始化
bool res = true;
//原理:lock,unlock-> 是原子的!!(为甚么?)
//一行代码是原子的:只有一行汇编的情况
pthread_mutex_lock(&mtx);
//mymtx.lock();
//执行这部分代码的执行流就是互斥的,串行执行的!
if(tickets > 0){
usleep(1000); //1s == 1000ms 1ms = 1000us
printf("我是[%u], 我要抢的票是: %d\n", pthread_self(), tickets);
tickets--;
printf("");
//抢票
}
else{
printf("票已经被抢空了\n");
res = false;
}
pthread_mutex_unlock(&mtx);
//mymtx.unlock();
return res;
}
~Ticket()
{
pthread_mutex_destroy(&mtx);
}
};
void *ThreadRoutine(void *args)
{
Ticket *t = (Ticket*)args;
//购票的时候,不能出现负数的情况
// srand((long)time(nullptr));
while(true)
{
if(!t->GetTicket())
{
break;
}
}
}
int main()
{
Ticket *t = new Ticket();
pthread_t tid[5];
for(int i = 0; i < 5; i++){
int *id = new int(i);
pthread_create(tid+i, nullptr, ThreadRoutine, (void*)t);
}
for(int i = 0 ; i < 5; i++){
pthread_join(tid[i], nullptr);
}
return 0;
}
代码运行结果:
通过运行结果我们可以看到,此时线程是安全的。
互斥锁的原理
可重入 VS 线程安全
1、线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
2、重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
线程安全一定是可重入的,可重入的不一定是线程安全的。
常见线程不安全的情况
1、不保护共享变量的函数
2、函数状态随着被调用,状态发生变化的函数
3、返回指向静态变量指针的函数
4、调用线程不安全函数的函数
常见线程安全的情况
1、每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的
2、类或者接口对于线程来说都是原子操作
3、多个线程之间的切换不会导致该接口的执行结果存在二义性
死锁四个必要条件
1、互斥条件:一个资源每次只能被一个执行流使用
2、请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
3、不剥夺条件:一个执行流已获得的资源,在末使用完之前,不能强行剥夺
4、循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系
<2>线程同步
条件变量
创建
pthread_cond_t 变量名
初始化
int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict attr);
参数:cond:要初始化的条件变量。attr:NULL
销毁
int pthread_cond_destroy(pthread_cond_t *cond)
参数:要销毁的条件变量
等待
int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
cond:要在这个条件变量上等待
mutex:互斥量
唤醒等待
int pthread_cond_broadcast(pthread_cond_t *cond); 唤醒所有线程
int pthread_cond_signal(pthread_cond_t *cond); 唤醒一个线程(等待队列中的第一个线程)
用一个线程去控制其他线程示例代码:
#include<iostream>
#include<unistd.h>
#include<pthread.h>
using namespace std;
pthread_mutex_t mtx;
pthread_cond_t cond;
//ctrl thread 控制work线程,让他定期运行
void *ctrl(void* args)
{
string name = (char*)args;
while(true)
{
//pthread_cond_signal: 唤醒在条件变量下等待的一个线程,哪一个??
//在cond 等待队列里等待的第一个线程
cout << "master say: begain work" << endl;
//唤醒一个进程
pthread_cond_signal(&cond);
//唤醒所有进程
//pthread_cond_broadcast(&cond);
sleep(2);
}
}
void *work(void* args)
{
int number = *(int*)args;
delete (int*)args;
while(true)
{
pthread_cond_wait(&cond, &mtx);
cout << "worker[" << number << "]is working ..." << endl;
}
}
int main()
{
#define NUM 3
pthread_mutex_init(&mtx, nullptr);
pthread_cond_init(&cond, nullptr);
pthread_t master;
pthread_t worker[NUM];
pthread_create(&master, nullptr, ctrl, (void*)"boss");
for(int i = 0; i < NUM; i++)
{
int *number = new int(i);
pthread_create(worker + i, nullptr, work, (void*)number);
}
for(int i = 0; i < NUM; i++)
{
pthread_join(worker[i], nullptr);
}
pthread_join(master,nullptr);
pthread_mutex_destroy(&mtx);
pthread_cond_destroy(&cond);
return 0;
}
运行结果:
可见我们成功用一个线程,控制了其他线程。并且每回唤醒的都是等待队列中第一个线程。
pthread_cond_wait(&cond,&mtx)为什么参数中要有一个互斥锁呢?
因为wait被调用的时候,会首先自动释放锁,然后挂起自己。(没有锁的话,那么他就会抱着锁挂起,程序也就死锁了),返回的时候,会首先自动竞争锁,获取到锁之后才能返回。
生产者消费者模型
为何要使用生产者消费者模型?
生产者消费者模式就是通过一个容器来解决生产者和消费者的强耦合问题。生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。
我们通过日常生活中的一个例子来画图理解一下:
基于阻塞队列的生产消费模型代码:gitee代码
POSIX信号量
POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。
初始化信号量
#include <semaphore.h>
int sem_init(sem_t *sem, int pshared, unsigned int value);
参数:
sem:信号量
pshared:0表示线程间共享,非零表示进程间共享
value:信号量初始值
销毁信号量
int sem_destroy(sem_t *sem);
等待信号量
功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem);
发布信号量
功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);
基于环形队列的生产消费模型代码:gitee代码
代码实现原理:
读者写者问题
读写锁
在编写多线程的时候,有一种情况是十分常见的。那就是,有些公共数据修改的机会比较少。相比较改写,它们读的机会反而高的多。通常而言,在读的过程中,往往伴随着查找的操作,中间耗时很长。给这种代码段加锁,会极大地降低我们程序的效率。那么有没有一种方法,可以专门处理这种多读少写的情况呢? 有,那就是读写锁.
注意:写独占,读共享,写锁优先级高
设置读写优先
int pthread_rwlockattr_setkind_np(pthread_rwlockattr_t *attr, int pref);
pref 共有 3 种选择
PTHREAD_RWLOCK_PREFER_READER_NP (默认设置) 读者优先,可能会导致写者饥饿情况
PTHREAD_RWLOCK_PREFER_WRITER_NP 写者优先,目前有 BUG
PTHREAD_RWLOCK_PREFER_WRITER_NONRECURSIVE_NP 写者优先,但写者不能递归加锁
初始化
头文件:#include<pthread.h>
int pthread_rwlock_init(pthread_rwlock_t *restrict rwlock,const pthread_rwlockattr_t *restrict attr);
参数:
rwlock:是要进行初始化的锁
attr:是rwlock的属性。此参数一般不关注,可设为NULL
销毁
int pthread_rwlock_destroy(pthread_rwlock_t *rwlock);
参数:
rwlock:是需要进行销毁的锁
加锁和解锁
int pthread_rwlock_rdlock(pthread_rwlock_t *rwlock); 在进行读操作的时候加的锁;
int pthread_rwlock_wrlock(pthread_rwlock_t *rwlock); 在进行写操作的时候加的锁;
int pthread_rwlock_unlock(pthread_rwlock_t *rwlock); 对读/写统一进行解锁;
<3>线程池
概念
- 一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。
(通俗来说,就是提前准备好一批线程用来随时处理任务,就成为线城池。)
线程池应用场景:
- 需要大量的线程来完成任务,且完成任务的时间比较短。
- 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
- 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。
普通线程池代码:普通线程池
<4>单例模式
什么是单例模式?
单例模式是一种 “经典的, 常用的, 常考的” 设计模式。某些类, 只应该具有一个对象(实例), 就称之为单例。(构造函数私有化,禁用拷贝构造函数和赋值等于函数)。
什么是设计模式?
大佬们针对一些经典的常见的场景, 给定了一些对应的解决方案, 这个就是 设计模式。
饿汉实现方式和懒汉实现方式
- 饿汉方式:在类加载时就完成了初始化,但是加载比较慢,获取对象比较快
- 懒汉方式:在类加载的时候不被初始化。
二者区别
- 线程安全:饿汉式在线程还没出现之前就已经实例化了,所以饿汉式一定是线程安全的。
- 执行效率:饿汉式没有加任何的锁,因此执行效率比较高。懒汉式一般使用都会加同步锁,效率比饿汉式差。
- 内存使用:饿汉式在一开始类加载的时候就实例化,无论使用与否,都会实例化,所以会占据空间,浪费内存。懒汉式什么时候用就什么时候实例化,不浪费内存。
单例模式线程池代码:单例模式线程池
更多推荐
所有评论(0)