【Linux】多线程编程 - 概念/pthread库调用接口/互斥

目录

一.线程概念

1.Linux中线程如何实现

2.POSIX线程库: pthread第三方线程库

3.线程与进程的数据存放问题

4.线程的"高效"具体体现在哪

5.线程优缺点

二.线程控制

0.关于pthread_t类型

1.线程创建

2.线程终止

3.线程等待

4.线程分离

5.代码验证

三.线程互斥

1.线程互斥概念

1).概念

2).为什么必须互斥访问临界区

3).关于并发, 并行, 串行

2.互斥量(锁)

1).互斥量(锁)/加锁/解锁

2).互斥量(锁)的实现原理

3).关于加锁的"粒度"问题

4).死锁/如何避免死锁

四.线程安全vs可重入函数


一.线程概念

摘自百度:

线程(英语:thread)是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。一条线程指的是进程中一个单一顺序的控制流,一个进程中可以并发多个线程,每条线程并行执行不同的任务。

1.Linux中线程如何实现

关于线程, 每套OS在实现时, 都有不同的实现方案, 在Linux中, 线程共享进程的地址空间

关于进程:

每个进程中都有至少有一个属于自己的PCB, 在Linux中PCB被抽象为task_struct结构体, 指向该进程自己的虚拟地址空间, 进程是OS分配资源的最基本单位

在进程中没有线程产生之前, 每个进程都只有一个PCB指向自己的虚拟地址空间, 该PCB也可以称之为当前进程的主执行流, 当该进程中产生多个线程时, LinuxOS对于每一个线程都会创建一个PCB, 这n个PCB共享同一个虚拟地址空间, 多个线程可以看到同一个空间, 这也就为多线程协同工作提供了便捷的条件

如何理解线程是CPU调度的最小单位呢?

Linux中对于CPU而言, 是不区分进程or线程的, CPU只会去调度内存中的每一个task_struct(PCB)

如何理解进程是OS分配资源的最基本单位呢?

进程, 是主执行流PCB, 与多个线程PCB, 与虚拟地址空间等一系列与该进程有关的事物的总称, OS无法为线程分配资源, 因为线程是寄托于进程之下的(因为线程的虚拟地址空间来源于进程), 也就是说创建一个进程时, 资源就已经分配给该进程了, 比如: 虚拟地址空间等等

总结: 线程寄托于进程, 是在进程的地址空间内运行的, 也是OS调度的最小单位

在Linux中, 实际是没有真正的线程内核数据结构的, 线程的实现是仿照着进程去实现的, 利用了进程的task_struct, 通过多个task_struct(即多个执行流)共享同一块地址空间的方式, 具备了多线程(即多执行流)的并发性, 减少了创建线程的成本(即只需要创建一个task_struct即可), 同时也降低了进程与线程间设计的复杂性, 所以我们把Linux中的线程统一称之为: 轻量级进程

摘自百度:

线程在Unix System V及SunOS中也被称为轻量进程(lightweight processes),但轻量进程更多指内核线程(kernel thread),而把用户线程(user thread)称为线程。

所以当有了线程的概念, 反观进程, 创建一个进程, 相当于向OS申请资源, 当OS向该进程分配了资源(内存, 内核数据结构等等), 该进程只有一个PCB, 也就只有一个执行流, 即主执行流, 当创建多个线程之后, 这些线程就去共享了进程的PCB, 就会有多个执行流在进程的地址空间内部并发执行!

图示:

2.POSIX线程库: pthread第三方线程库

由于Linux的线程设计方案, 使得线程并没有属于自己的内核数据结构, 在关于创建与使用线程的调用接口选择了与进程相同的系统调用clone(其实fork()/vfork()创建子进程底层也是调用的clone), 通过传参的方式来确定当前是否需要申请地址空间与内核数据结构等等, 这样的接口极其复杂, 所以就有了pthread第三方库, 其内部基于Linux原本的接口封装了创建与使用线程的一系列接口, 叫做第三方库只是因为这是后来单独加上的一个线程库并没有在Linux诞生时就有, 现在大部分Linux系统都自带这个第三方库, 但是在使用的时候, 即gcc/g++编译时需要带上选项 -lpthread 告诉编译器的库名(因为这是第三方库, 既不属于语言库也不属于系统库) 

pthread第三方线程库是在用户层实现了一套用户层多线程方案(对系统调用的封装, 降低了学习成本, 以库的方式供用户使用), Linux下, C++的线程库底层调用的都是pthread第三方线程库接口

3.线程与进程的数据存放问题

线程与进程共享地址空间, 具体共享了哪些空间呢?

可共享:

共享区

堆区

未初始化全局数据区

已初始化全局数据区

代码区

不可共享:

栈区不可以直接共享

注: 栈区数据可以通过代码来间接的完成共享, 但是极度不推荐! 如果想要共享的数据最好创建在堆上

线程内部的资源数据又存在哪里呢?

现在我在进程中创建了一个线程, 并且在这个线程中创建了一批的临时变量, 此时这些临时变量存放在哪里? 首先肯定不是在栈区的, 因为栈区并不能直接共享数据

实际上线程也是拥有"自己的空间的", 这部分空间是被pthread库(第三方库)维护的, 在Linux上所有语言创建线程底层都是去调用pthread库中的接口, 再由库中的接口去调用系统调用的

线程"自己的空间"在pthread库上都维护了哪些内容:

1.线程ID

2.一组寄存器(重点)

3.栈(重点)

注意: 该栈并不是地址空间上的栈区, 是pthread第三方库维护的, 然后通过共享区加载动态库的方式来加载到共享区并且进行访问的

那么共享区是可以被共享的, 如何确保这是线程自己的空间?

第三方库帮我们维护好了, 对于使用者而言是透明的, 所以并不影响"线程空间"的独立性

4.errno

5.信号屏蔽字

6.调度优先级

4.线程的"高效"具体体现在哪

对于CPU而言, 所谓的调度进程或线程, 实际上就是调度一个个PCB, CPU是不区分线程/进程的, 那么线程的切换比进程的切换成本更低, 体现在哪里?

当调度同一进程内的PCB时, 由于同一进程下的多个线程共享地址空间, 所以地址空间与页表等不需要切换, 并且cache的三级缓存(L1~L3)也是不需要切换的(cache三级缓存是为了进一步减小CPU与内存间的工作效率相对差距较大问题, 对内存的代码和数据, 根据局部性原理, 从内存中预读到三级缓存, 缓存的速度比内存更快, 但也更昂贵, 故空间较小)

当调度不同进程内的PCB时, cache三级缓存会立即失效, 由于对新进程的调度, 而需要重新缓存以及切换地址空间与页表

所以调度线程成本更低的体现, 就是如上所说, 本质上: 更少次的切换"数据"

5.线程优缺点

相比于进程

优点: 

创建开销少, 占用资源少

调度切换成本低

可利用多处理器的可并行数量, 并发处理任务

计算/IO密集型应用, 本质上高并发执行, 节省时间

缺点:

编程难度提高, 如果代码编写能力差, 则会带来程序健壮性降低问题, 缺乏访问控制问题

注意:

关于性能问题, 并不是线程越多性能就越高

一般情况下, 创建线程数量 == CPU核心处理器数量, 效率最佳(如果是单核CPU, 一般不创建线程)

根据问题不同, 合理创建线程, 才能发挥线程并发执行最大价值, 否则过多的线程只会做无意义的线程调度切换, 反而影响了效率

二.线程控制

统一的, 都包含#include <pthread.h>, 返回值都为int, 成功返回0, 失败返回error number

0.关于pthread_t类型

线程空间由pthread第三方库维护, pthread_t本质上是一个指向struct pthread的指针

struct pthread结构体内部封装有线程局部存储数据, 线程栈...

关于线程的id, 我们无法直接打印出来(可以通过ps -aL命令查看), 一般都是创建pthread_t tid变量将地址传入pthread_create作为输出型参数, 也就是整块struct pthread结构体的指针, 我们只需要向接口传入该指针的地址, 接口内部的代码逻辑就可以操作整个线程了, tid并不是线程id, 而是指向动态库维护某一线程区域的首地址, 本篇文章称其为AddrID(地址ID)

pthread_self()

函数pthread_t pthread_self()可以在线程内部获取该线程AddrID

1.线程创建

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

参数:

pthread_t *thread: 输出型参数, 将整个第三方库创建的该线程空间的struct pthread地址放到thread变量中

const pthread_attr_t *attr: 设置线程属性, 传入nullptr即可

void *(*start_routine) (void *): 线程执行的函数, 以回调函数的方式使用, 传入函数指针

void *arg: 想要喂给线程函数的数据, 将自动以参数形式传入线程执行的函数

2.线程终止

void pthread_exit(void *retval);

哪个线程调用就终止哪个线程

参数:

void *retval: 类似线程返回值, 线程结束后将把retval返回给pthread_join(), 传入nullptr则表示没有返回值

注意: 

线程返回值 or 线程调用pthread_exit的retval返回值, 不能返回在线程栈上开辟的内容, 就类似于函数不能返回在栈上开辟的内容, 只能返回全局or开辟在堆区的内容

int pthread_cancel(pthread_t thread);

取消一个执行中的线程, 可以从内部cancel也可以从外部cancel
参数:

pthread_t thread: 线程AddrID

注意:

1).如果线程出现异常

例如: 除零错误, 整个进程全部异常退出

2).exit or _exit终止线程

整个进程都被终止

3).调用pthread_exit()从内部终止线程

只有调用该函数的线程被终止, 相当于return

4).调用pthread_cancel()从内/外部终止线程

直接终止指定线程

3.线程等待

int pthread_join(pthread_t thread, void **retval);

1.线程等待与进程等待相同, 已经退出的线程, 其task_struct内核数据结构并没有释放, 新建的线程不会复用原来线程的task_struct, 如果不等待回收已经结束的线程就会造成内存泄漏

2.只能以阻塞方式等待

参数:

pthread_t thread: 线程AddrID

void** retval: 线程函数的返回值是void*类型, retval做为输出型参数, 可以接收线程的返回值, 传入nullptr则表示不关心返回值

4.线程分离

int pthread_detach(pthread_t thread);

1.默认情况下, 一个线程结束后必须被pthread_join回收, 否则就会造成内存泄漏

2.线程分离后, 线程结束自动释放资源, 不会造成内存泄漏, 表明join(即回收线程)已经成为了负担, 将不再关心线程的返回值, 此时就不能再对该分离的线程pthread_join, 否则报错

3.可以在线程内部进行分离pthread_detach(pthread_self()), 也可以由其他执行流将其进行分离

参数:

pthread_t thread: 线程AddrID

5.代码验证

#include<iostream>
#include<pthread.h>
#include<unistd.h>
#include<cstring>

using namespace std;

#define THREAD_NUM 3

//线程函数
void* threadFunc(void* args)
{
  //char* log = (char*)args;//输出全部都是thread 3,注意!!,因为char* log是一个指针,指向主执行流栈区,内容最终会被thread 3覆盖
  string log = (char*)args; //可以正确输出

  //每个线程执行5s,每秒打印线程信息
  int count = 5;
  while(count--)
  {
    cout << pthread_self() << ": " << log << endl;
    sleep(1);
  }
  string* ret = new string(log);

  return (void*)ret->c_str();
  //或者pthread_exit((void*)ret->c_str());
}

int main()
{
  //定义线程tid,即AddrID
  pthread_t tid[THREAD_NUM];
  char buffer[32];
  //创建THREAD_NUM个线程
  for(int i = 0; i < THREAD_NUM; ++i)
  {
    snprintf(buffer, sizeof buffer, "%s %d", "thread", i + 1);
    pthread_create(tid + i, nullptr, threadFunc, (void*)buffer);
    sleep(1);
  }
  //pthread_join等待回收
  for(int i = 0; i < THREAD_NUM; ++i)
  {
    void* ret = nullptr;
    pthread_join(tid[i], &ret);
    printf("回收: thread %d, 接收返回值: %s\n", i + 1, (char*)ret);
  }

  return 0;
}

三.线程互斥

1.线程互斥概念

1).概念

临界资源: 被多个执行流共享的资源称为临界资源

临界区: 每个执行流访问临界资源的代码称为临界区

互斥: 任何时刻, 只能有一个执行流进入临界区访问临界资源, 对临界资源起到保护作用

原子性: 不会被任何调度机制打断的操作, 要么完成, 要么未开始

2).为什么必须互斥访问临界区

我们写的代码最终都会由编译器编译成汇编, 再转成二进制目标文件, 再链接形成.exe文件

在汇编层面上, 我们的一行代码, 会被编译成n条指令, 所以每行代码执行起来并不是一气呵成的

(注意: 一行代码被编译成多条汇编, 汇编是原子的)

多个线程间, 可以并发的执行指令, 如果不对临界区以互斥的方式加以保护, 就会出现线程安全问题

例如: 用以下抢票逻辑举例

为了能够保护临界资源, 多个线程需要串行访问临界区

3).关于并发, 并行, 串行

多个线程是并发执行的, 但线程执行的逻辑并不一定都是并行的, 并发是指多个线程同时开始

非临界区: 并行执行, 同一时刻可以有多个线程进入非临界区, 访问非临界资源

临界区: 串行执行, 同一时刻只能有一个线程进入临界区, 访问临界资源

2.互斥量(锁)

1).互斥量(锁)/加锁/解锁

能够实现线程互斥的方式有许多种, 互斥量是其中之一

互斥量的定义与初始化

互斥量类型: pthread_mutex_t

定义与初始化方式: 1.定义为全局或者静态的, 用宏初始化pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

                               2.定义为局部的, 用函数初始化, 使用结束必须释放

                                int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict attr);

                                int pthread_mutex_destroy(pthread_mutex_t *mutex);

加锁

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex); -- trylock会尝试加锁, 如果该锁已经被其他线程占有则返回错误码, 否则则加锁成功返回0

解锁

int pthread_mutex_unlock(pthread_mutex_t *mutex);

多线程抢票抢票代码

#include <iostream>
#include <pthread.h>
#include <assert.h>
#include <unistd.h>
using namespace std;

static const size_t THREAD_NUM = 3;

// 第一种定义锁的方式
//pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; 

int ticketsNum = 3000;

struct ThreadData
{
  ThreadData(const string& name, pthread_mutex_t* mtxPtr)
   :_threadName(name)
   ,_mtxPtr(mtxPtr)
  {}

  string _threadName;
  pthread_mutex_t* _mtxPtr;
};

void *tickets(void *args)
{
  ThreadData* ptd = (ThreadData*)args; // 可以正确输出
  // 模拟抢票的逻辑
  while (1)
  {
    // 加锁
    pthread_mutex_lock(ptd->_mtxPtr);
    if (ticketsNum > 0)
    {
      // 模拟正在抢票...
      usleep(1500);
      cout << ptd->_threadName << ':' << ticketsNum << endl;
      ticketsNum--;
      // 解锁
      pthread_mutex_unlock(ptd->_mtxPtr);
    }
    else
    {
      // 解锁
      pthread_mutex_unlock(ptd->_mtxPtr);
      break;
    }
    // 模拟抢到票之后...
    usleep(2000);
  }
  return nullptr;
}

int main()
{
  // 创建线程
  pthread_t t[THREAD_NUM];
  
  // 第二种定义锁的方式
  //static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

  // 第三种定义锁的方式
  pthread_mutex_t mutex;
  pthread_mutex_init(&mutex, nullptr);

  char buffer[32];

  for (size_t i = 0; i < THREAD_NUM; ++i)
  {
    snprintf(buffer, sizeof buffer, "%s %zu", "thread", i + 1);
    //pthread_create(t + i, nullptr, tickets, (void *)buffer);
    
    //string buffer = "Thread ";
    //buffer += to_string(i + 1);
    ThreadData* ptd = new ThreadData(buffer, &mutex); 
    pthread_create(t + i, nullptr, tickets, (void*)ptd);

    //sleep(1);
  }

  // 回收线程
  for (size_t i = 0; i < THREAD_NUM; ++i)
  {
    pthread_join(t[i], nullptr);
    cout << "回收线程: " << "thread " << i + 1 << endl;
  }
  
  // 回收锁
  pthread_mutex_destroy(&mutex);

  return 0;
}

2).互斥量(锁)的实现原理

原子性: 一件事要么不发生, 如果发生就必须一气呵成

互斥量是为了保护临界资源的, 而多个线程需要申请或释放互斥量来达成这一目的, 可是多个线程可以同时看到同一个互斥量, 那么这个互斥量就必然也属于临界资源, 又是谁来保护互斥量呢?

其实, 互斥量的实现是具有原子性的, 通过一条汇编的方式来保证它的原子性

3).关于加锁的"粒度"问题

对于加锁而言当然, 越细粒度的加锁, 程序的效率会有所上升

越细粒度简单粗暴点说, 就是在加锁解锁之间的代码越少越好

但, 同时也要注意频繁加锁解锁问题

如何理解这句话呢, 举个例子:

方式一: 正确

加锁

for(size_t i = 0; i < 1000; ++i)

{

    cout << "hello" << endl;

}

解锁

方式二: 错误

for(size_t i = 0; i < 1000; ++i)

{

    加锁

    cout << "hello" << endl;

    解锁

}

表面上来看方式二加锁的代码少, 但实际上, 方式二在循环内加锁, 且循环中的代码简单, 程序会频繁的加锁解锁, 这就带来了无意义的消耗; 而方式一非常理想, 加一次锁就可以顺利的执行下去

4).死锁/如何避免死锁

什么是死锁

多个线程互相持有对方需要的锁资源, 互不退让, 使得线程无法继续向下执行, 就会发生死锁

产生死锁的四个必要条件

1.互斥: 一个资源每次只能被一个执行流访问

2.请求与保持: 一个执行流在持有另一个执行流需要的锁资源且不主动释放的同时, 去申请另一个执行流已经拥有的锁资源

3.不剥夺: 每个执行流在申请对方拥有的锁资源时不能强行剥夺

4.循环等待: 若干执行流之间形成一种首尾相接的循环等待资源的关系

如何避免死锁

只要破坏掉上述产生死锁的四个条件之一, 就可避免死锁

1.不申请锁

2.使用pthread_mutex_trylock(), 如果申请不到锁就返回错误码, 且手动释放自己持有的锁

3.通过某种方式强制剥夺对方已占有的锁资源

4.加锁顺序一致, 避免环路等待

四.线程安全vs可重入函数

可重入函数: 一个函数可以同时被多个执行流(包含自身这个执行流, 例如递归)进入且不出任何问题, 通常指不含有或访问临界资源的函数

线程安全函数: 多个线程可以并发执行该函数且不出现任何问题, 该函数可以含有或访问临界资源

区别:

是否可重入, 只是一个函数的性质; 而是否线程安全, 则决定一个函数能否被多个线程使用

线程安全不一定可重入, 可重入一定线程安全

不可重入函数不能由多个线程使用, 可能引发线程安全问题

1.如果一个函数中访问了临界资源,那么它既不线程安全的,也不可重入;
2.如果对它加以改进,在访问临界资源时加锁,则可以使它变成线程安全的,但此时它仍然是不可重入的,因为通常加锁方式是针对不同线程的访问,而对同一线程可能出现问题——死锁
3.如果将函数中的临界资源去掉,改成函数参数等其他形式,则有可能使函数变成既线程安全,又可重入。

  • 5
    点赞
  • 20
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值