零基础Linux

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6

目录

1. 线程安全

1.1 线程不安全前期

1.2 线程不安全原因

2. 线程互斥

2.1 加锁保护代码

2.2 锁的本质

3. 可重入对比线程安全

4. 死锁

4.1 死锁的必要条件

4.2 避免死锁

5. 笔试面试题

答案及解析

本篇完。


1. 线程安全

基于上一篇线程控制这里创建个linux_23文件在里面写代码先看一段模拟抢票的代码

Makefile

mythread:mythread.cc
	g++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:
	rm -f mythread

mythread.cc创建了三个新线程抢票

#include <iostream>
#include <cstdio>
#include <cerrno>
#include <cstring>
#include <thread>
#include <unistd.h>
#include <pthread.h>
using namespace std;

// 如果多线程访问同一个全局变量并对它进行数据计算多线程会互相影响吗
int tickets = 10000; // 在并发访问的时候导致了我们数据不一致的问题

void *getTickets(void *args)
{
    (void)args;
    while(true)
    {
        if(tickets > 0)
        {
            usleep(1000);
            printf("%p: %d\n", pthread_self(), tickets);
            tickets--;
        }
        else
        {
            break;
        }
    }
    return nullptr;
}

int main()
{
    pthread_t t1,t2,t3,t4;
    // 多线程抢票的逻辑
    pthread_create(&t1, nullptr, getTickets, (void*)"user1");
    pthread_create(&t2, nullptr, getTickets, (void*)"user2");
    pthread_create(&t3, nullptr, getTickets, (void*)"user3");
    pthread_create(&t4, nullptr, getTickets, (void*)"user4");

    pthread_join(t1, nullptr);
    pthread_join(t2, nullptr);
    pthread_join(t3, nullptr);
}

编译运行

运行以后发现出现了负数票这不合理票抢完就应该停止了包括我们的代码逻辑都是这样写的但是此时就出现了这种情况。

  • 上面现象的原因是发生了线程不安全问题

为什么产生了线程不安全现象

上面现象故意弄出来的涉及到了线程调度利用了线程调度的特性造出了一个这样的现象。要想出现上面的现象就需要尽可能让多个线程交叉执行。多个线程交叉执行的本质就是让调度器尽可能的频繁发生线程调度与切换。

虽然看起来是多个线程在同时运行但这是由于CPU运行速度太快导致的实际上CPU是一个线程一个线程执行的。现在就是要让CPU频繁调度不停的切换线程一个线程还没有执行完就再执行下一个每个线程都执行一点这样交叉执行。

当一个线程进行延时的时候CPU并不会等它而是会将它放在等待队列里然后去执行另一个线程等延时线程醒来以后才会接着执行。

线程在时间片到来更高优先级线程到来线程等待的时候会发生线程切换。

线程是在从内核态转换成用户态的时候检测是否达到线程切换的条件的。

线程检测是否切换是以内核态的身份去检测的执行的是3~4G内核空间中的代码本质上是操作系统在检测。


1.1 线程不安全前期

假设tickets已经只剩一张了即全局变量tickets = 1。

主线程创建好4个新线程以后4个新线程便开始执行了在执行到延时的时候新线程就会被放在等待队列里。看CPU及内核

if(tickets > 0)判断的本质逻辑 从内存中读取数据到CPU寄存器 ->  进行判断。

在线程user1执行到if判断时CPU从内存中将tickets变量中的数据1拿到了CPU的寄存器ebx中。

CPU进行判断后发现符合大于0的条件。

当线程user1符合条件继续向下执行延时代码时CPU将线程user1切走了换上了user2。

在线程user1被切走的时候它的上下文数据也会被切走。

所以ebx寄存器中的1也会跟着user1的PCB被切走。

user2被调度时仍然重复user1的过程执行延时被切走再换上user3以此类推直到user4被切走。四个线程都拿到了tickets=1所以符合条件都能向下执行。

当user4被挂起后user1差不多就该醒来了。user1唤醒以后接着被切走的位置继续执行

执行tickets - - 的本质

  • 从内存中读取数据到CPU的寄存器
  • 更改数据
  • 写回数据到内存中

虽然C/C++代码只有一条语句但是汇编后至少有3条语句。

user1执行tickets–以后抢票成功了并且将抢票后的tickets=0写回到了内存中。

此时user2醒来了同样接着它被切走的位置继续执行此时user2回来后认为tickets=1所以就向下执行了

当执行tickets减减时仍然需要三步

  • 从内存中读取tickets=0到CPU寄存器ebx中。
  • 修改值从0变成-1。
  • 将-1写回内存中。

当user2执行完后user3和user4醒来同样继续向下执行重复上面的过程仍然对tickets减一所以导致结果不合理。


1.2 线程不安全原因

只存在两个线程对全局变量tickets仅作减减操作

线程A先被CPU调度进行减减操作。

  • 从内存中将tickets=1000取到寄存器ebx中。
  • 进行减减操作tickets变成了999。
  • 在执行第三步写回数据之前线程A被切走了。

线程A切走的同时它的上下文也就是tickets=999也被切走了。

线程B此时被调度线程A在等待队列。

  • 线程B先从内存中读取tickets = 1000到寄存器ebx中。
  • 进行减减操作。
  • 将减减后的值写回到内存中。
  • 线程B将减减操作完整的执行了很多遍直到tickets=200时才被切下去。

线程B被切走以后线程A又接着被调度。

线程A接着被切走的位置开始执行也就是执行减减的第三步操作壹壹写回。

  • 线程A被调度后先恢复上下文将被切走时的tickets=999恢复到了ebx寄存器中。
  • 然后执行第三步将tickets=999写回到了内存中。

线程B辛辛苦苦将tickets从1000减到了200线程A重新被调度后直接将tickets又从200写回到了999。上面这种现象被叫做数据不一致问题

  • 导致数据不一致问题的原因共享资源没有被保护多线程对该资源进行了交叉访问。

而解决数据不一致问题的办法就是对共享资源加锁。


2. 线程互斥

看看几个基本概念

临界资源多个执行流进行安全访问的共享资源。

上面现象中的tickets很显然就不是临界资源因为多线程对它的访问并不安全存在数据不一致问题。

临界区多个执行流中访问临界资源的代码。

假设上面例子中的是临界资源那么每个线程都存在一部分临界区就是对tickets进行判断打印减减部分的代码。多个线程中的这部分代码属于临界区。

线程互斥让多个线程串行访问共享资源任何时候只有一个执行流在访问共享资源。

上面例子中如果多个线程能够串行访问tickets而不是交叉访问也不会产生数据不一致问题。而让共享资源变成临界资源就是为了实现互斥也就是让多个线程串行访问原本的共享资源。

原子性对一个资源进行访问的时候要么不做要么就做完。

在C/C++中的减减和加加操作看似是一句代码但是对应着三条汇编指令上面例子中线程A在执行第三步之前被切走了导致减减操作没有完成这种行为就不具有原子性因为对共享资源的操作没有做完。

对一个资源进行操作如果只用一条汇编就能完成那么就具有原子性反之就不具有原子性。这是当前的一种理解这种理解只能算原子性中的一个子集是为了方便表述。


2.1 加锁保护代码

要想解决多线程的数据不一致问题就需要做到以下几点

  • 代码必须要有互斥行为当一个线程进入临界区执行代码时不允许其他线程进入该临界区
  • 如果有多个线程同时请求执行临界区代码并且临界区没有线程在执行代码那么只允许一个线程进入该临界区。
  • 如果线程不在临界区中执行代码那么该线程不能阻止其他线程进入临界区。

要做到上面三点只需要一把锁就可以持有锁的线程才能进入临界区中执行代码并且其他线程无法进入该临界区。

锁就是互斥量也叫互斥锁。

加锁可以让共享资源临界资源化从而保护共享资源的安全让多个线程串行访问共享资源。

锁相关的系统调用

pthread_mutex_t lock; // 定义一把锁

和创建线程一样锁也需要创建POSIX提供了锁的变量类型如上面代码所示其中mutext是互斥量的意思。

初始化锁man pthread_mutex_init

int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);
  • 形参1创建的互斥锁指针
  • 形参2锁的属性一般情况下设为nullptr
  • 返回值初始化成功返回0失败返回错误码
  • 作用将创建的锁初始化。

销毁锁

int pthread_mutex_destroy(pthread_mutex_t *mutex);
  • 形参创建的互斥锁指针
  • 返回值销毁成功返回0失败返回错误码
  • 作用当锁使用完后必须进行销毁

全局或者静态锁初始化

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

如果锁是全局的或者被static修饰的静态锁只需要使用上面语句初始化锁即可。

加锁man pthread_mutex_lock

int pthread_mutex_lock(pthread_mutex_t *mutex);
  • 形参创建的互斥锁指针
  • 返回值加锁成功返回0失败返回错误码
  • 作用给临界区加锁让多线程串行访问临界资源

解锁

int pthread_mutex_unlock(pthread_mutex_t *mutex);
  • 形参创建的互斥锁指针
  • 返回值解锁成功返回0失败返回错误码
  • 作用解锁让多线程恢复并发执行

锁其实起一个区间划分的作用在加锁和解锁之间的代码就是临界区多个执行流只能串行执行临界区代码从而保护公共资源使之成为临界资源。

pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(lock);
//临界区
//...
pthread_mutex_unlock(lock);

加锁和解锁两句代码圈定了临界区的范围。

现在将抢票代码加上锁看看是否还会出现多线程数据不一致问题

Makefile

mythread:mythread.cc
	g++ -o $@ $^ -g -std=c++11 -lpthread
.PHONY:clean
clean:
	rm -f mythread

mythread.cc

在主线程中创建一个互斥锁并且初始化在所有新线程等待成功后将锁释放。

但是此时的锁是存在于主线程的栈结构中需要让所有新线程看到这把锁。创建成全局就不用

在线程数据类中再增加一个锁指针此时所有线程就都能看到这把锁了。

#include <iostream>
#include <cstdio>
#include <cerrno>
#include <cstring>
#include <cassert>
#include <thread>
#include <unistd.h>
#include <pthread.h>
using namespace std;

int tickets = 10000; // 在并发访问的时候导致了我们数据不一致的问题 -> 临界资源

#define THREAD_NUM 10

class ThreadData
{
public:
    ThreadData(const std::string &n,pthread_mutex_t *pm):tname(n), pmtx(pm)
    {}
public:
    std::string tname;
    pthread_mutex_t *pmtx;
};

void *getTickets(void *args)
{
    ThreadData *td = (ThreadData*)args;
    while(true) // 抢票逻辑
    {
        int n = pthread_mutex_lock(td->pmtx); // 加锁
        assert(n == 0);
        // 临界区
        if(tickets > 0) // 判断的本质也是计算的一种
        {
            usleep(rand()%1500);
            printf("%s: %d\n", td->tname.c_str(), tickets);
            tickets--; // 也可能出现问题
            n = pthread_mutex_unlock(td->pmtx); // 解锁
            assert(n == 0);
        }
        else
        {
            n = pthread_mutex_unlock(td->pmtx);  // break之前解锁
            assert(n == 0);
            break;
        }
        
        usleep(rand()%2000); // 抢完票其实还需要后续的动作
    }
    delete td;
    return nullptr;
}

int main()
{
    time_t start = time(nullptr);
    pthread_mutex_t mtx;
    pthread_mutex_init(&mtx, nullptr);

    pthread_t t[THREAD_NUM];

    for(int i = 0; i < THREAD_NUM; i++) // 多线程抢票的逻辑
    {
        std::string name = "thread ";
        name += std::to_string(i+1);
        ThreadData *td = new ThreadData(name, &mtx);
        pthread_create(t + i, nullptr, getTickets, (void*)td);
    }

    for(int i = 0; i < THREAD_NUM; i++)
    {
        pthread_join(t[i], nullptr);
    }

    pthread_mutex_destroy(&mtx);
    return 0;
}

此时抢票的结果是正常了最终抢到1结束符合我们的预期。

但发现抢票的速度比以前慢了好多。

因为加锁和解锁的过程是多个线程串行执行的并且临界区的代码也是串行执行的所以速度就变慢了。

需要注意的是

  • 当一个线程从临界区中出来并且释放锁后执行后续任务时其他线程才有更大几率去竞争锁。
  • 加锁时一定要保证临界区的粒度非常小。将那些不是必须放在临界区中的代码放在临界区外。
  • 加锁是程序员行为要加锁就所有线程都加锁否则就起不到保护共享资源的效果。

2.2 锁的本质

如何看待锁

在上面代码中一个锁必须让所有线程都看到所以锁本身就是一个共享资源。

既然是共享资源锁也必须是安全的那么是谁来保证锁的安全性呢

锁是通过加锁和解锁是原子的来保证自身的安全的。

一个线程如果申请成功锁那么它就会继续向下执行如果暂时申请不成功呢

此时就被阻塞住了线程和进程都是存在的。

  • 一个锁只能被申请一次只有锁被释放后才能再次申请。

当一个线程申请锁暂时失败以后就会阻塞不动。

  • 当一个线程申请锁成功进入临界区访问临界资源其他线程要想进入临界区只能阻塞等待等锁释放。
  • 当一个线程申请锁成功进入临界区访问临界资源同样是能被切走的而且该线程是抱着锁走的其他线程仍然无法申请锁成功。

操作系统内部并不存在锁的概念所以调度器在调度轻量级进程的时候并不会考虑是否有锁。

所以站在其他线程的角度锁只有两种状态

  • 申请锁前
  • 申请锁后

站在其他线程的角度看到当前持有锁的过程就是原子的。

加锁解锁的原理

经过上面的例子我们认识到一个事实c/c++中加加和减减的操作并不是原子的所以会导致多线程数据不一致的问题。

而为了能让加锁过程是原子的在大多数体系结构了都提供了swap或者xchange汇编指令通过一条汇编指令来保证加锁的原子性。

加锁解锁的伪汇编代码

lock:
	movb %al, $0
	xchange %al, mutex
	if(al寄存器的内容 > 0)
	{
		return 0;
	}
	else
	{
		挂起等待;	
	}
	goto lock;

unlock:
	movb mutex, $1
	唤醒等待mutex的线程;
	return 0;

加锁过程中xchange是原子的可以保证锁的安全。

锁只能被一个线程持有而且由于xchange汇编只有一条指令即使申请锁的过程被切走也不怕。

一旦一个线程通过xchage拿到了锁即使它被切走也是拿着锁走的其他线程是无法拿到锁的只有等它将锁释放。

只有持有锁的线程才能执行下去锁相当于一张入场卷。

这样来看释放锁的过程其实对原子性的要求并没有那么高因为释放锁的线程必定是持有锁的线程不持有锁的线程都不会执行到这里都在阻塞等待。


3. 可重入对比线程安全

重入同一个函数被不同的执行流调用当前一个流程还没有执行完就有其他的执行流再次进入我们称之为重入。

之前在信号部分就提到过重入进程在执行一个函数收到某个信号在处理信号时又调用了这个函数。今天在多线程这里理解重入更加容易我们以前写的多线程代码都是可重入的。

可重入和不可重入一个函数在重入的情况下运行结果不会出现任何不同或者任何问题则该函数被称为可重入函数否则是不可重入函数。

常见可重入情况

  •  不使用全局变量或静态变量。
  •  不使用用malloc或者new开辟出的空间。
  •  不返回静态或全局数据所有数据都有函数的调用者提供。

常见可重入情况

  •  不使用全局变量或静态变量。
  •  不使用用malloc或者new开辟出的空间。
  •  不返回静态或全局数据所有数据都有函数的调用者提供。

总的来说一个函数中如果使用了全局数据或者静态数据以及堆区上的数据就是不可重入的反之就是可重入的。

线程安全

多个线程并发同一段代码时不会出现不同的结果(数据不一致)。常见对全局变量或者静态变量进行操作并且没有锁保护的情况下会出现该问题。

互斥锁就是让不安全的线程变安全也就是前面我们所学习的内容。

常见线程安全情况

  • 每个线程对全局变量或者静态变量只有读取的权限而没有写入的权限一般来说这些线程是安全的。
  • 类或者接口对于线程来说都是原子操作。
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性。

多线程共同执行的代码段中如果有全局变量或者静态变量没有被保护那么就是线程不安全的。

常见线程不安全情况

  • 不保护共享变量的函数。
  • 函数状态随着被调用状态发生变化的函数。
  • 返回指向静态变量指针的函数。

可重入与线程安全的联系

多线程是通过调用函数来实现的所以线程安全和重入就存在一些联系

  • 函数是可重入的那就是线程安全的因为没有全局或者静态变量不会产生数据不一致问题。
  • 函数是不可重入的那就不能由多个线程使用有可能引发线程安全问题。出发对不可重入函数的全局变量进行保护。
  • 如果一个函数中有全局变量并且没有保护那么这个函数既不是线程安全也不是可重入的。

可重入与线程安全的区别

可重入和线程安全是不同的两个东西但是又存在一定的交集。

  • 可重入说的是函数。
  • 线程安全说的是线程。

可重入函数是线程安全函数的一种因为不存在全局或者静态变量。

线程安全不一定是可重入的而可重入函数则一定是线程安全的。因为线程安全的情况可能是对全局变量进行了保护加了锁。

由于线程可以加锁所以说线程安全的情况比可重入要多。


4. 死锁

我们前面例子中写的都是只有一把锁的情况在实际使用中有可能会存在多把锁此时就可能造成死锁。

死锁一组执行流中的各个执行流均占有不会释放的锁资源但因互相申请被其他进程所站用不会释放的锁资源而处于的一种永久等待状态。

通俗来说就是一个线程自己持有锁并且不会释放但是还要申请其他线程的锁此时就容易造成死锁。

一把锁也是会有死锁的情况的连续申请俩次就是死锁。

在上面演示一个线程暂时申请锁失败而阻塞时就是死锁。


4.1 死锁的必要条件

死锁的四个必要条件

① 互斥

这一点不用说只要用到锁就会互斥。

② 请求与保持

请求就是指一个执行流申请其他锁保持是指不释放自己已经持有的锁。

③ 不剥夺

一个执行流已经持有锁在不主动释放前不能强行剥夺。

④ 环路等待

线程ABC都持有一把锁并且不释放。

  • 线程A 申请 线程B持有的锁B
  • 线程B 申请 线程C持有的锁C
  • 线程C 申请 线程A持有的锁A

此时就构成了环路阻塞等待。

只有符合上面四个条件就会造成死锁。而要破坏死锁只要破坏其中一个条件即可。


4.2 避免死锁

① 上面四个必要条件中的第一个无法破坏因为我们使用的就是锁锁就具有互斥的性质。只能破坏其他三个条件。

② 加锁顺序一致

这是为了避免形参环路等待只要不构成环路即可。

③ 避免锁位释放的场景

④ 避免锁位释放的场景

临界资源尽量一次性分配好不要分布在太多的地方加锁这样的话导致死锁的概率就会增加。

解决死锁的基本方法如下

预防死锁、避免死锁、检测死锁、解除死锁。

解决死锁的常用策略如下

鸵鸟策略 对可能出现的问题采取无视态度前提是出现概率很低

预防策略 破坏死锁产生的必要条件

避免策略 银行家算法分配资源前进行风险判断避免风险的发生

检测与解除死锁 分配资源时不采取措施但是必须提供死锁的检测与解除手段

可以避免预防死锁的算法了解

  • 死锁检测算法
  • 银行家算法避免策略

银行家算法的思想在于将系统运行分为两种状态安全/非安全有可能出现风险的都属于非安全。

银行家算法的思想是为了避免出现“环路等待”条件


5. 笔试面试题

1. 以下描述正确的有

A.可以使用ps -l命令查看轻量级进程信息

B.可以使用ps -L命令查看轻量级进程信息

C.可以使用pthread_self接口获取轻量级进程ID

D.可以使用getpid接口获取轻量级进程ID

2. 以下描述正确的有[多选]

A.pthread_create函数是一个库函数 代码当中如果使用该函数创建线程 则需要在编译的时候链接“libpthread.so”线程库

B.那个线程调用pthread_exit函数 那个线程就退出。俗称“谁调用谁退出”

C.在有多个线程的情况下主线程调用pthread_cancel(pthread_self()), 则主线程状态为Z 其他线程正常运行

D.在有多个线程的情况下主线程从main函数的return返回或者调用pthread_exit函数则整个进程退出

3. 下列不属于POSIX互斥锁相关函数的是

A.int pthread_mutex_destroy(pthread_mutex_t* mutex)

B.int pthread_mutex_lock(pthread_mutex_t* mutex)

C.int pthread_mutex_trylock(pthread_mutex_t* mutex)

D.int pthread_mutex_create(pthread_mutex_t* mutex)

4. 进程A、B共享变量x需要互斥执行

    进程B、C共享变量yB、C也需要互斥执行

    因此进程A、C必须互斥执行

A.错

B.对

5. 设两个进程共用一个临界资源的互斥信号量mutex当mutex1时表示。

A.一个进程进入了临界区另一个进程等待

B.没有一个进程进入临界区

C.两个进程都进入临界区

D.两个进程都在等待

6. 在一段时间内只允许一个进程访问的资源被称为

A.共享资源

B.临界区

C.临界资源

D.共享区

7. 简述轻量级进程ID与进程ID之间的区别

8. 简述LWP与pthread_create创建的线程之间的关系

9. 简述什么是LWP

10. 简述什么是线程互斥为什么需要互斥


答案及解析

1. B

A错误

B正确 ps命令用于查看进程信息其中-L选项用于查看轻量级进程信息

C错误 pthread_self() 用于获取用户态线程的tid而并非轻量级进程ID

D错误 getpid() 用于获取当前进程的id,而并非某个特定轻量级进程

2. ABC

C主线程调用pthread_cancel(pthread_self())函数来退出自己 则主线程对应的轻量级进程状态变更成为Z 其他线程不受影响这是正确的正常情况下我们也不会这么做....

D主线程调用pthread_exit只是退出主线程并不会导致进程的退出

3. D

A pthread_mutex_destroy 用于销毁互斥锁

B pthread_mutex_lock 用于加锁保护临界区

C pthread_mutex_trylock 用户非阻塞加锁

D 没有这个函数 pthread_create是线程创建函数而互斥锁并没有对应的创建函数而是直接定义pthread_mutex_t类型的互斥锁变量

4. A

进程A操作的xC并不进行操作进程C操作的y进程A并不操作因此A和C并不需要互斥执行

5. B

mutex简单理解就是一个0/1的计数器用于标记资源访问状态

0表示已经有执行流加锁成功资源处于不可访问

1表示未加锁资源可访问。

因此选择B选项表示没有执行流完成加锁对资源进行访问资源处于可访问状态。

6. C

A 共享资源表示能够被多个执行流同时访问的资源

B 对临界资源进行操作的代码段被称作临界区

C 临界资源表示同一时间只能有一个执行流访问的共享资源

D 没有这个专业说法非要简单理解就是可以共同执行的代码片段

题目为选择同一时间只有一个进程能访问的资源则就是临界资源因此选择C选项

7. 简述轻量级进程ID与进程ID之间的区别

因为Linux下的轻量级进程是一个pcb每个轻量级进程都有一个自己的轻量级进程IDpcb中的pid而同一个程序中的轻量级进程组成线程组拥有一个共同的线程组ID

8. 简述LWP与pthread_create创建的线程之间的关系

pthread_create是一个库函数功能是在用户态创建一个用户线程而这个线程的运行调度是基于一个轻量级进程实现的

9. 简述什么是LWP

LWP是轻量级进程在Linux下进程是资源分配的基本单位线程是cpu调度的基本单位而线程使用进程pcb描述实现并且同一个进程中的所有pcb共用同一个虚拟地址空间因此相较于传统进程更加的轻量化

10. 简述什么是线程互斥为什么需要互斥

线程互斥指的是在多个线程间对临界资源进行争抢访问时有可能会造成数据二义因此通过保证同一时间只有一个线程能够访问临界资源的方式实现线程对临界资源的访问安全性

本篇完。

下一篇零基础Linux_24多线程线程同步+条件变量+生产者消费模型。

阿里云国内75折 回扣 微信号:monov8
阿里云国际,腾讯云国际,低至75折。AWS 93折 免费开户实名账号 代冲值 优惠多多 微信号:monov8 飞机:@monov6
标签: linux