一、线程

1.关于线程的理解

每一个线程都是一个执行流。线程的执行流就是在创建线程时,设置的线程执行入口函数。

linux学习之线程_互斥

linux学习之线程_多线程_02

线程创建以及线程执行流函数都有着自己的要求。

创建线程的四个参数分别表示,线程id的地址,线程属性,线程执行流函数(也是一个地址),线程执行流函数的参数。

在线程执行流函数的设计上也要求返回值和参数类型也必须是void*

将上述代码经过编译链接后,执行可执行程序,可以看到在main函数内部的语句和newpthread函数内部的语句都被执行了,这就是所谓的两个执行流。

linux学习之线程_多线程_03

linux学习之线程_互斥_04

如果需要用到多个执行流,也可以创建多个线程,分配各自的执行流函数。

可以看到,多线程在被创建之后,在显示器上打印的各自执行流的内容的顺序也是会改变的。这可能是因为,多个线程在竞争显示器资源。

2.多线程

每个线程就表示一个执行流,多线程无非是多个执行流。上述多个线程的执行内容是不同的,但实际上在输出消息的时候都是在竞争显示器资源。上述多线程完全可以用一个线程函数来实现。如下

linux学习之线程_同步_05

linux学习之线程_同步_06

也就是说,多线程虽然是多个执行流,但很可能,多个线程的最终目的是相同的,就是为了竞争同一份资源。

3.基于抢火车票的多线程逻辑

linux学习之线程_多线程_07

linux学习之线程_线程池_08

num代表的是火车票的票数,每个线程就是抢票者,线程不断的争抢火车票,票数大于0则抢票成功,否则抢票失败。这是正常的抢票逻辑,但是用多线程区实现抢票逻辑的时候,存在严重的安全问题。

4.线程安全和可重入函数

利用多线程模拟抢票逻辑时,抢票成功,会对票数进行--。看似合理,但因为--语句不是原子性的,就会导致线程在抢票时,非法抢票,比如说,还剩下3张票,有5个抢票线程,前2个线程在抢票时,都是判断有票后,进行--操作过程中,被切换到下一个线程执行抢票。

所谓的原子性指的就是一条语句(一条指令),只有已执行和未执行,没有正在执行过程中这一概念。而--操作这条语句,他的底层是由多条汇编语句构成,而汇编指令才是指令执行的基本单位,是被一次性执行完的。所以说,--操作不是原子性的,它可能处于执行中。这就会导致,在当前线程处于执行过程中时,切换到其他线程执行,导致非法抢票的bug出现。

可重入函数

如果一个函数可以同时被多个不同执行流进入,不会引发问题,则被称为可重入函数,反之被称为不可重入函数。像抢票逻辑的执行函数就是不可重入函数,因为该函数没有对临界资源保护,可能出现线程安全问题。

线程安全

多线程实现的抢票逻辑之所以会有线程安全问题,是因为每个线程对临界资源都有写的能力。而多个线程并发访问同一段代码,如果不会出现不同的结果,则被称为线程安全。

而对于线程不安全的代码,可以通过加锁保护。

5.如何保证线程安全

1.互斥锁--互斥

多线程同时访问临界资源时会引发线程安全,是因为访问临界资源的语句不具有原子性,如果具有原子性,就不会存在指令执行过程中这一引发线程安全的问题。

什么是互斥锁?

互斥锁是资源量为1的资源,获得互斥锁资源需要加锁,归还互斥锁资源需要解锁,加锁和解锁操作是原子性的,通过对临界资源的加锁和解锁,可以解决临界资源的线程安全问题。

linux学习之线程_多线程_09

linux学习之线程_线程池_10

linux学习之线程_互斥_11

每个线程在这执行抢票逻辑时,需要先竞争互斥锁资源,而互斥锁资源只有一个,先拿到锁的线程才可以继续向下执行代码,其他线程在拿到锁的线程没有解锁释放锁资源之前,只能阻塞在对互斥锁资源的争抢中。只有当互斥锁资源被释放,其余线程竞争,胜利者才能继续执行代码。这样,多线程互斥的访问临界区资源,避免了多线程并发访问临界资源引发的线程安全问题。

互斥锁保证了多线程访问临界资源的互斥性,但是由于线程之间对锁的竞争能力不同,第一次抢到锁的线程,即使在释放锁之后,对锁的竞争能力依旧是最强的,这就会导致第一次抢到锁的线程不断抢到锁,引起其它线程的饥饿问题。

加锁和解锁的本质。

互斥信号量在内存中的初始值为1,加锁会将互斥信号量在内存中的值和某个寄存器中存储的数值交换,寄存器中的初始值会先设置为0。交换后会进行判断,如果寄存器的值为一,则加锁函数返回,继续执行代码,如果为0,则阻塞在加锁函数。

而解锁就是将互斥信号量的值恢复为1,然后唤醒阻塞在加锁函数的其他线程。

加锁和解锁对应的操作,是一条基本指令,是原子的,所以可以保证自身的安全。

如果只看互斥信号量本身,那么第一个来到加锁函数,判断互斥信号量的值是1,存在资源,所以置0后继续向下执行,其他线程也来到加锁函数,互斥信号量表示的资源数已经为0,不存在资源,那就只能阻塞等待。

2.条件变量--同步

linux学习之线程_多线程_12

linux学习之线程_同步_13

可以通过让拿到锁资源的线程在解锁之后睡眠1秒,降低其竞争能力解决线程饥饿问题。但通常使用条件变量来优雅的解决。

什么是条件变量?

多线程并发访问临界资源时,存在的线程安全通过互斥锁,由互斥锁控制多线程做到对临界资源的互斥访问,保证了线程安全。但是互斥锁会引发新的线程饥饿问题。条件变量的本质是一个阻塞队列,与互斥锁配合使用,让抢到锁的线程在阻塞队列中排队,原本处于阻塞队列头部的线程在任务执行完后,会在阻塞队列的尾部进行排序,这样保证了多线程执行的顺序性,也即保证了线程同步。如下

linux学习之线程_同步_14

linux学习之线程_线程池_15

linux学习之线程_多线程_16


条件变量本身也是一种资源,表示的是一个阻塞队列。关于条件变量下的等待和唤醒操作,和互斥信号量的加锁和解锁操作一样,都是对变量本身的资源数操作,二者的操作都是原子性的。

条件变量必须和互斥锁对应的互斥信号量搭配使用,这是为了防止死锁的发生。拿到锁资源的线程在条件变量下的阻塞队列中等待时,会将该线程的锁资源释放,也就是解锁。

条件变量的使用既然也是原子性的,为什么加锁操作在等待操作之前?

这是因为在条件变量下等待,会先解锁释放锁资源,再进入阻塞队列。而唤醒操作会加锁获取锁资源。

如果先等待,即使被唤醒,也会阻塞在加锁上。如下

linux学习之线程_同步_17

linux学习之线程_互斥_18

所有拿到锁的线程都会在条件变量下的阻塞队列中等待,并且该操作会将锁资源归还,如果不归还,该线程就会带着锁资源阻塞在条件变量下的阻塞队列中,而在阻塞队列下等待的线程也并不会继续执行,而是在阻塞队列中阻塞等待,只有唤醒阻塞队列中的线程,才会让对应的线程向下执行代码。

唤醒操作会唤醒阻塞队列中的线程,只有唤醒之后才可以让线程继续执行。这也是为什么唤醒操作是由主线程一直唤醒,而不是在线程执行函数内部,如果在线程执行函数内部访问临界资源之后,那么线程直接会被阻塞在等待函数下,根本不会有唤醒的机会。如下。

linux学习之线程_线程池_19

linux学习之线程_线程池_20

因为唤醒会进行加锁操作的原因,只有等访问完临界资源后的解锁之后,才能有新的线程进入阻塞队列。

整个执行流的逻辑如下,先来的线程加锁后继续向下执行代码,阻塞在条件变量的等待队列中,此时等待函数会进行解锁,锁资源已经归还,此时可能会有新的线程抢到锁,再进入到阻塞队列中,直到通过唤醒操作,唤醒了阻塞队列中的线程,才会继续向下执行,并且唤醒伴有加锁操作,一旦唤醒,就不会有新的进程能进入到阻塞队列,而是先在加锁获取锁资源中等待。直到被唤醒的线程执行完任务再次解锁后,才会有新的线程进入阻塞队列排队。


3.死锁

上述错误案例,无论是先等待后加锁,还是将唤醒操作放到等待之后,都会使得已有的资源得不到释放,从而让其余线程一直处于阻塞等待状态。这种线程处于永久等待的状态也被称为死锁。

6.生产者和消费者模型

1.基于阻塞队列的生产者消费者

所谓的生产者消费者模型,无非是将上述线程同步和互斥对子资源的竞争进行一定的封装。如下

linux学习之线程_互斥_21

linux学习之线程_互斥_22

linux学习之线程_多线程_23

linux学习之线程_线程池_24

push的逻辑并不难理解,就是将所谓的数据放入阻塞队列即可。阻塞队列也属于临界资源,所以访问前需要加锁,并且临界区对于数据的存储是有限的,所以只有阻塞队列未满时才可以放入数据,如果已满则生成者线程需要被阻塞,而不管阻塞队列满还是未满,都需要唤醒消费者进行消费。

上述代码唯一有难点的地方就在于,push和pop中对于阻塞队列的判断为什么是循环?而不是一个if语句。如下

linux学习之线程_同步_25

linux学习之线程_互斥_26

一个if语句的情况:如果同时存在多个生产者线程被阻塞,在唤醒时,多个生产者线程被错误的唤醒,导致多个阻塞的生产者线程被同时唤醒,虽然只有其中一个线程能在唤醒后继续执行代码,但是对于类外几个被误唤醒的线程,虽然没有在被唤醒的几个线程中抢到优先执行权,但是也已经不在阻塞队列当中。当被唤醒的线程执行完后,其余被唤醒的线程中又会有一个幸运儿被选中继续执行代码,此时这个幸运儿就会直接继续向下执行代码,并且是没有判断是否还有剩余空间的情况下,这就很可能会出现,存储空间不足,但是依然向阻塞队列放入数据的非法操作。因此,这里必须是循环判断,当幸运儿向后执行代码前,必须再次队存储空间进行判断。

基于阻塞队列的生产者消费者模型,需要在生产和消费前判断是否能生产和消费。生产者关心的是是否有空间,消费者关心的是是否有任务(数据)。虽然关注内容不同,但实际上生产和消费都是对阻塞队列的访问,所以需要先加锁,并且只需要一把互斥锁即可。而在实现生产者和消费者的同步上,也是各自使用了条件变量。


2.基于环形队列的生产者消费者

linux学习之线程_同步_27

linux学习之线程_互斥_28

linux学习之线程_多线程_29

linux学习之线程_同步_30

linux学习之线程_多线程_31

基于环形队列的生产者消费者模型中,生产者关心的依然是空间,消费者依然关心的是数据。但和基于阻塞队列不同的是,前者是通过判断语句来判断,后者是直接利用POSIX信号量进行判断。并且POSIX信号量也能保证线程间同步。可以理解为信号量内部也有一个队列在对争抢资源的线程排队,并且PV操作也都是原子性的。在该模型下,用数据模拟的环形队列的下标才是临界资源,只要生产者消费者的下标不是指向同一个位置,就可以同时访问。而当为空或者为满时,二者指向的下标是相同的,因此为空时只能生产者执行,为满时只能消费者执行。PV操作本质是对生产者和消费者关注的资源数做++和--。只有大于0,资源存在时,才会向下执行,否则会一直阻塞。

7.线程池

多线程在上文早已经创建过很多次,只不过都是通过主线程创建的。

线程池其实就是对多线程进行封装,让我们直接通过类和对象创建并使用多线程。

ThreadPool.hpp如下

#pragma once
#include <iostream>
#include <vector>
#include <string>
#include <queue>
#include <pthread.h>
#include <unistd.h>
struct ThreadInfo//包含线程信息的结构体
{
    pthread_t tid;
    std::string name;
};
static const int defalutnum = 5;
template <class T>
class ThreadPool
{
public:
    void Lock()//加锁
    {
        pthread_mutex_lock(&mutex_);
    }
    void Unlock()//解锁
    {
        pthread_mutex_unlock(&mutex_);
    }
    void Wakeup()//唤醒
    {
        pthread_cond_signal(&cond_);
    }
    void ThreadSleep()//等待
    {
        pthread_cond_wait(&cond_, &mutex_);
    }
    bool IsQueueEmpty()//判断阻塞队列是否为空
    {
        return tasks_.empty();
    }
    std::string GetThreadName(pthread_t tid)//获取线程名
    {
        for (const auto &ti : threads_)
        {
            if (ti.tid == tid)
                return ti.name;
        }
        return "None";
    }

public:
    static void *HandlerTask(void *args)//线程入口函数
    {
        ThreadPool<T> *tp = static_cast<ThreadPool<T> *>(args);
        std::string name = tp->GetThreadName(pthread_self());
        while (true)
        {
            tp->Lock();

            while (tp->IsQueueEmpty())
            {
                tp->ThreadSleep();//阻塞队列为空,线程被阻塞
            }
            T t = tp->Pop();//不断消费
            tp->Unlock();//解锁

            t();
            std::cout << name << " run, "
                      << "result: " << t.GetResult() << std::endl;
        }
    }
    void Start()//创建多线程
    {
        int num = threads_.size();
        for (int i = 0; i < num; i++)
        {
            threads_[i].name = "thread-" + std::to_string(i + 1);
            pthread_create(&(threads_[i].tid), nullptr, HandlerTask, this);
        }
    }
    T Pop()//拿走阻塞队列中的数据
    {
        T t = tasks_.front();
        tasks_.pop();
        return t;
    }
    void Push(const T &t)//向阻塞队列放入数据
    {
        Lock();//加锁
        tasks_.push(t);
        Wakeup();//唤醒线程消费
        Unlock();//解锁
    }
    static ThreadPool<T> *GetInstance()
    {
        if (nullptr == tp_) // ???
        {
            pthread_mutex_lock(&lock_);
            if (nullptr == tp_)
            {
                std::cout << "log: singleton create done first!" << std::endl;
                tp_ = new ThreadPool<T>();
            }
            pthread_mutex_unlock(&lock_);
        }

        return tp_;
    }

private:
    ThreadPool(int num = defalutnum) : threads_(num)
    {
        pthread_mutex_init(&mutex_, nullptr);
        pthread_cond_init(&cond_, nullptr);
    }
    ~ThreadPool()
    {
        pthread_mutex_destroy(&mutex_);
        pthread_cond_destroy(&cond_);
    }
    ThreadPool(const ThreadPool<T> &) = delete;
    const ThreadPool<T> &operator=(const ThreadPool<T> &) = delete; // a=b=c
private:
    std::vector<ThreadInfo> threads_;
    std::queue<T> tasks_;

    pthread_mutex_t mutex_;
    pthread_cond_t cond_;

    static ThreadPool<T> *tp_;//指向线程池对象的指针,为单例准备
    static pthread_mutex_t lock_;
};

template <class T>
ThreadPool<T> *ThreadPool<T>::tp_ = nullptr;
//static成员变量,只能类内声明,类外定义
template <class T>
pthread_mutex_t ThreadPool<T>::lock_ = PTHREAD_MUTEX_INITIALIZER;

Main.cpp,如下

linux学习之线程_多线程_32

封装好的线程池内并不需要提前向阻塞队列放入数据,这个工作一定是由创建线程池的对象自己添加的,线程池创建的多个线程只是不断从阻塞队列中取走数据,如果没有数据,线程就被阻塞,直到新的数据被添加,才会唤醒阻塞的线程

类内线程执行函数只能设置为静态成员函数,否则会多出一个this指针,参数不匹配。因此在类内创建线程池时,传递的参数就是this指针。如下

linux学习之线程_同步_33

linux学习之线程_互斥_34

关于单例模式:一句话形容就是,让一个类只有一个实例对象。

关于单例模式的实现方法有两种:懒汉方式和饿汉方式。

懒汉方式指的是,对象在被创建前已经存在,

饿汉方式指的是,对象会在需要用到时被创建。

关于饿汉方式,会在类内提供一个指向类的静态指针。在类内提供一个对外可以实例化对象的接口函数。如下

linux学习之线程_同步_35

linux学习之线程_线程池_36

tp指针指向一个线程池对象并初始化为空,如果指针为空,表示对象没有被创建,否则创建一个对象,并且该函数的返回值指向的就是创建的线程池对象,可以在类外调用该函数,通过返回值,对单例模式的对象进行操作。如下

linux学习之线程_线程池_37

对于静态成员变量和成员函数,在对象被创建前已经存在(已经开辟空间)。

如下

linux学习之线程_多线程_38

linux学习之线程_多线程_39

使用饿汉方式实现单例模式时,在创建类对象时,之所以加锁并再次判断,是为了防止多线程同时调用该函数,防止创建多个对象的一种保护措施。

实现单例模式的类内函数只能是static,只有这样才能在类外无需创建对象(无需this指针)就能调用,确保类只有一个对象。

8.模拟C++线程的使用

上述所有线程,全部是基于linux。

如下是用linux线程来封装出一个windows版本的线程池。

thread.hpp

#pragma once

#include <iostream>
#include <string>
#include <ctime>
#include <pthread.h>

typedef void (*callback_t)();//函数指针
static int num = 1;

class Thread
{
public:
    static void *Routine(void *args)
    {
        Thread* thread = static_cast<Thread*>(args);
        thread->Entery();//线程执行的任务通过函数指针回调
        return nullptr;
    }
public:
    Thread(callback_t cb):tid_(0), name_(""), start_timestamp_(0), isrunning_(false),cb_(cb)
    {}
    void Run()//创建线程
    {
        name_ = "thread-" + std::to_string(num++);
        start_timestamp_ = time(nullptr);
        isrunning_ = true;
        pthread_create(&tid_, nullptr, Routine, this);
    }
    void Join()//线程回收
    {
        pthread_join(tid_, nullptr);
        isrunning_ = false;
    }
    std::string Name()
    {
        return name_;
    }
    uint64_t StartTimestamp()
    {
        return start_timestamp_;
    }
    bool IsRunning()
    {
        return isrunning_;
    }
    void Entery()
    {
        cb_();
    }
    ~Thread()
    {}
private:
    pthread_t tid_;
    std::string name_;
    uint64_t start_timestamp_;
    bool isrunning_;

    callback_t cb_;//回调的函数指针,创建函数时,会用类外的函数将其初始化
};

main.cc

#include <iostream>
#include <unistd.h>
#include <vector>
#include "Thread.hpp"

using namespace std;

void Print()
{
    while(true)
    {
        printf("haha, 我是一个封装的线程...\n");
        sleep(1);
    }
}

int main()
{
    std::vector<Thread> threads;

    for(int i = 0 ;i < 10; i++)
    {
        threads.push_back(Thread(Print));
    }

    for(auto &t : threads)
    {
        t.Run();
    }


    for(auto &t : threads)
    {
        t.Join();
    }
    // Thread t(Print);
    // t.Run();

    // cout << "是否启动成功: " << t.IsRunning() << endl;
    // cout << "启动成功时间戳: " << t.StartTimestamp() << endl;
    // cout << "线程的名字: " << t.Name() << endl;

    // t.Join();

    return 0;
}