Linux系统编程:多线程(1)

目录

1.Linux线程的概念

1.1 什么是线程?

1.2 进程和线程的区别

1.3 线程的优缺点

2.线程控制

2.1 线程创建

2.1.1 函数介绍

2.1.2 多线程具体的实现 

2.1.3 线程的共享资源

2.1.3 线程ID

2.2 线程等待

2.3 线程终止

2.4 线程分离 

3.线程互斥 

3.1 进程线程间的互斥相关概念

3.2 互斥量

3.3 互斥量接口介绍

3.4 可重入和线程安全

3.4.1 概念

3.4.2 线程安全/不安全情况

3.4.3 可重入/不可重入情况

3.4.4 可重入与线程安全联系/区别

4.锁 

4.1 死锁的四个必要条件

4.2 避免死锁的方法


1.Linux线程的概念

1.1 什么是线程?

        线程就是进程内的一个执行流。进程相信大家都了解过了,那执行流又是什么呢?下面先简单回顾一下进程的概念,顺便引出线程的含义!

        在以前的学习中我们知道,进程=内核数据结构+进程对应的代码和数据。每个进程有自己的PCB(task_struct),虚拟地址空间(虚拟内存里面决定了进程能够看到的“资源”。),页表,还有所映射的物理地址。下图为一个进程的模型结构

进程模型结构

        如果我们把虚拟内存中的各个区域,页表都划分为若干个小区域,一部分供当前的进程使用。接着再创建几个"进程PCB",此时创建的“进程”不进行写时拷贝,不创建进程地址空间和页表。和task_struct指向同一个进程地址空间、页表,如下图所示。

        对这种情况该如何解释呢?对只创建PCB,从当前的进程分配资源的执行流就叫做线程。因为我们可以通过虚拟地址空间+页表对进程进行资源划分,单个"进程"执行粒度,一定要比之前的进程要细。线程再进程的地址空间内运行,拥有该进程的一部分资源。通俗一点讲就是进程内的所有线程组成了一个进程。线程负责进程的部分代码。

        经过上面的叙述,那么我们可以重新叙述进程,就是多个PCB,一个进程地址空间,多级页表,所映射对应的物理内存。这里的每个PCB可以称之为轻量级进程。那么我们之前讲的进程是什么呢?其实它是包含一个线程的进程。

1.2 进程和线程的区别

从上面看,进程和线程是紧密相关联的,那么他俩的区别又是什么呢?

进程

线程

承担分配系统资源的基本实体

        (用来整体申请资源)

CPU调度的基本单位

(向进程要资源)

进程具有独立性线程的大部分资源是共享的
代码数据和数据结构的总和只占有进程部分的资源

得出几点结论:

  •  Linux内核中没有真正意义的线程,使用进程PCB来模拟线程的
  • 站在CPU的视角,每一个PCB,都可以称之为轻量级进程
  • Linux线程是CPU调度的基本单位,进程是承担分配系统资源的基本单位
  • 进程使用来整体申请资源的,线程向进程要资源
  • Linux无法直接提供创建线程的系统调用接口,只能提供创建轻量级进程的接口。

1.3 线程的优缺点

优点缺点
创建线程比创建进程代价小健壮性/鲁棒性较差
线程切换比进程切换要简单的多编写与调试难度提高
线程占用的资源较少

缺乏访问控制

可以并行运行
计算密集型应用,I/O密集型应用

2.线程控制

2.1 线程创建

2.1.1 函数介绍

#include <pthread.h>

int pthread_create(pthrea_t* thread, const pthread_att_t* attr, void*(*start_routine)(void*), void* arg);

参数:

  • pthread_t* thread:相当于无符号的整型,这里暂且称之为线程id
  • const pthread_att_t* attr:设置线程的属性,当前先设置为nullptr
  • void*(*start_routine)(void*):函数指针,线程将要执行的该函数
  • void* arg:将传入上面函数的参数

返回值:成功返回0,失败返回错误码。

使用此方法必须链接libpthread-2.17.so原生线程库。任何Linux都必须默认携带改库。编译时加

-lpthread选项。

命令行查看线程的命令:ps -aL

PID和LWP相同的为主线程,不同的为新线程。CPU调度时,是以LWP为标识符表示特定一个执行流的。

2.1.2 多线程具体的实现 

代码1:

#include <iostream>
#include <cassert>
#include <cstdio>
#include <pthread.h>
#include <unistd.h>

using namespace std;

void func();



// 新线程
void *thread_routine(void *args)
{
    const char *name = (const char*)args;
    while(1)
    {
        cout << "我是新线程, 名字为:" << name << ":";
        
        fflush(stdout);
        sleep(1);
    }
}

int main()
{
    pthread_t tid;
    int n = pthread_create(&tid, nullptr, thread_routine, (void*)"thread one");
    assert(n == 0);
    (void)n;

    while(1)
    {
        char tidbuffer[64];
        snprintf(tidbuffer, sizeof(tidbuffer), "0x%x", tid);
        
        cout << "我是主线程,我创建出来的线程id为:" << tidbuffer << ":" ;
        
        fflush(stdout);
        sleep(1);
    }

    return 0;
}

结果: 

我们上面也提到了,线程会共享进程的资源,但是线程也是有自己的私有资源的,哪些资源应该是线程私有的呢?

  1. PCB属性
  2. 由于线程是动态运行的,所以线程要有自己的上下文数据。
  3. 每一个线程都要有自己独立的栈结构:局部数据

2.1.3 线程的共享资源

线程一经创建,几乎所有的资源都是被线程共享的。例如下面的代码:

代码2:

#include <iostream>
#include <cassert>
#include <cstdio>
#include <pthread.h>
#include <unistd.h>

using namespace std;

void func();

int val = 0;

// 新线程
void *thread_routine(void *args)
{
    const char *name = (const char*)args;
    while(1)
    {
        cout << "我是新线程, 名字为:" << name << ":";
        func();
        cout << " val:" << val++ << " &val:" << &val << endl;
        fflush(stdout);
        sleep(1);
    }
}

void func()
{
    cout << "我是一个独立的方法 ";
}

int main()
{
    pthread_t tid;
    int n = pthread_create(&tid, nullptr, thread_routine, (void*)"thread one");
    assert(n == 0);
    (void)n;

    while(1)
    {
        char tidbuffer[64];
        snprintf(tidbuffer, sizeof(tidbuffer), "0x%x", tid);
        
        cout << "我是主线程,我创建出来的线程id为:" << tidbuffer << ":" ;
        func();
        cout << " val:" << val << " &val:" << &val << endl;
        fflush(stdout);
        sleep(1);
    }

    return 0;
}

 由上述代码可以看到,不论是全局变量val,还是全局函数func(),均可被新进程和主进程共享。

2.1.3 线程ID

现在,我们再来讨论pthread_create函数的第一个参数,他与我们说的LWP id是不一样的。函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID。线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID:

代码4:

pthread_t pthread_self(void);

通过对代码3的更改,可以看出主线程和新线程的id是不同的。

代码3:

cout << "我是主线程,我创建出来的线程id为:" << tid << ":" << "我的id为:" << pthread_self();

 输出结果(由于CPU对线程的调度是随机的,一个线程没有执行完而去执行另一个线程,可能会导致输出有些混乱):

与进程切换相比,线程切换要简单的多,进程切换需要切页表、PCB、上下文、虚拟地址空间,而进程切换只需要切PCB和上下文即可。决定快慢的是CPU中的“cache”,线程切换cache不需要更新太多,进程切换cache全部更新。

2.2 线程等待

        在学习进程是,我们知道了进程等待的重要性。若线程不等待也会造成类似于僵尸进程的问题,导致内存泄漏等问题。

线程必须被等待:1.获取线程的退出信息;2.回收新线程对应的PCB等内核资源,防止内存泄漏。

函数声明:

#include <pthread.h>

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

参数:

  • pthread_t thread:线程ID
  • void **retval:输出型参数,用来获取线程函数结束时返回的结果(解释一下为什么这里要传二级指针,如果只传一级指针,不能够将结果带回来,因为线程函数返回的为void*类型的值,而只传void*,只是传了一个拷贝修改的也并不是原来地址里面的值。而传void**它传过去的是指针的拷贝,两个指针指向的地址空间是一样的,只修改一个指向地址的内容,另一个也会改变。)

返回值:成功返回0,失败返回错误码。

2.3 线程终止

线程终止有三种方法

  1. 线程函数结束,return时线程就算终止了。
  2. exit不能用来终止线程,它会使整个进程退出。

    #include <pthread.h>

    void pthread_exit(void *retval);
    哪个线程调用,哪个线程就终止。
    参数:void *retval其实就是刚刚我们线程等待中提到的线程函数结束时返回的结果。通过线程等待,在主线程中接受这个线程退出信息。

  3. 线程取消 取消成功返回0,失败返回非0值

    #include <pthread.h>

    int pthread_cancel(pthread_t thread);

下面用代码来演示线程等待与线程终止的具体实现:

代码4:

#include <iostream>
#include <vector>
#include <cassert>
#include <string>
#include <cstdlib>
#include <pthread.h>
#include <unistd.h>

using namespace std;

#define NUM 10

class ThreadData
{
public:
    int number;
    pthread_t tid;
    char namebuffer[64];
};

void *func(void *args)
{
    ThreadData *td = static_cast<ThreadData*>(args);

    cout << "我是新创建的线程,名字为" << td->namebuffer << " 编号为:" << td->number << endl;
    sleep(1);

    // 线程终止
    // 1
    return (void*)"线程终止!";

}

int main()
{
    vector<ThreadData*> threads;
    // pthread_t tid;
    // 通过循环创建十个线程,使其执行一个函数
    for(int i = 0; i < NUM; i++)
    {
        ThreadData *td = new ThreadData;
        td->number = i + 1;
        // 写每个进程的名字
        snprintf(td->namebuffer, sizeof(td->namebuffer), "%s:%d", "thread", i);
        pthread_create(&td->tid, nullptr, func, td);
        // 将创建的十个线程都放到容器中
        threads.push_back(td);
    }

    // 线程等待
    for(auto& thread:threads)
    {
        void* retval;
        int n = pthread_join(thread->tid, &retval);
        assert(n == 0);
        cout << "join:" << thread->namebuffer << " success, retval:" << retval << endl;
        delete thread;
        thread = nullptr;
    }

    while(true)
    {
        cout << "我是主线程" << endl;
        sleep(1);
    }

    return 0;
}

程序说明:上述代码为循环的创建了10个线程和一个主线程,新创建的线程打印其线程名字与编号,主线程打印一句话,然后进程等待,每个进程的推出信息为自己的名字。下图为输出结果,符合我们的预期。

2.4 线程分离 

        默认情况下,新创建的进程是joinable的,线程退出后需要等待,pthread_join操作。如果我们不关心线程的返回值,此时join就是一种负担,这时,可以告诉OS线程退出时自动释放资源。

int pthread_detach(pthread_t thread);

一个线程不能既是joinable,又是分离的。

可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离。

获取线程自己线程id的接口:

pthread_t pyhread_self(void); // 谁调用就获取谁的线程id

代码5:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>

void *thread_run( void * arg )
{
    pthread_detach(pthread_self());
    printf("%s\n", (char*)arg);
    return NULL;
}

int main( void )
{
    pthread_t tid;
    if ( pthread_create(&tid, NULL, thread_run, "thread1 run...") != 0 ) {
        printf("create thread error\n");
        return 1;
    }
    int ret = 0;
    sleep(1);//很重要,要让线程先分离,再等待
    if ( pthread_join(tid, NULL ) == 0 ) {
    printf("pthread wait success\n");
    ret = 0;
    } else {
        printf("pthread wait failed\n");
        ret = 1;
    }
    return ret;
}

代码11为线程分离的一个实例。之所以要加sleep(1),是因为并不能保证新线程在主线程前执行,所以我们在主线程这里休眠一秒,新线程会执行线程分离的代码,最后出现等待失败的现象,否则可能不会出现等待失败的现象。

3.线程互斥 

3.1 进程线程间的互斥相关概念

  • 临界资源:多个执行流进行安全访问的共享资源
  • 临界区:各执行流中,访问临界资源的代码
  • 互斥:任何时刻,互斥都保证只有一个执行流进入临界区,访问临界资源,通常对临界资源起到保护
  • 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。-- 也可以说是只由一条汇编完成的操作具有原子性。

3.2 互斥量

但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。多个线程并发的操作共享变量,会带来一些问题。例如下面的代码:

代码6:

# include <iostream>
# include <cstdio>
# include <string>
# include <cstring>
# include <memory> 
# include <vector>
# include <unistd.h>
# include <pthread.h>

#define NUM 4

// 共享资源
int tickets = 10000;

class ThreadData
{
public:

    ThreadData(const std::string &threadname)
        :_threadname(threadname)
        
    {}

    ~ThreadData(){}

public:
    std::string _threadname;
    
};

void *thread_run(void *args)
{
    // std::string username = static_cast<const char*>(args);
    ThreadData *td = static_cast<ThreadData*>(args);
    while(true)
    {
            if(tickets > 0)
            {
                usleep(1234); // 1s=1000ms=1000 000us
                // std::cout  << username << " 正在进行抢票:" << tickets << std::endl;

                std::cout  << td->_threadname << " 正在进行抢票:" << tickets << std::endl;
                // 用这段时间来模拟真实抢票花费的时间
                tickets--;
               
            }
            else 
            {
                
                break; 
            }        

        // 抢完票就完了吗? 模拟抢完票之后的流程
        usleep(1000);
    }
    return nullptr;
}

int main()
{
    
    std::vector<pthread_t> tids(NUM);
    for(int i = 1; i <= NUM; i++)
    {
        char namebuffer[64];
        snprintf(namebuffer, sizeof(namebuffer), "thread %d", i);
        ThreadData *td = new ThreadData(namebuffer);
        pthread_create(&tids[i-1], nullptr, thread_run, td);
    }

    for(const auto &tid:tids)
    {
        pthread_join(tid, nullptr);
    }

    pthread_mutex_destroy(&lock);


    return 0;
}

运行结果如下:

会出现负数的情况,那么这肯定是不行的。为何会出现这种情况呢?

  • if判断执为真以后,代码可能会并发的执行到其他的线程
  • usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段
  • --ticket 操作本身就不是一个原子操作

那么遇到这种情况该怎么做呢?需要做到以下三点:

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

做到这三点,本质上就是需要一把锁,Linux上提供的这把锁叫做互斥量

3.3 互斥量接口介绍

互斥量初始化:

  1. 静态分配 -- 如果一把锁是static或者全局的,这把锁就不需要init/destroy
    pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER; 
  2. 动态分配
    int pthread_mutex_init(pthread_mutex_t *restrict_mutex, const pthread_mutexattrt_t *restrictattr);
    参数介绍:
        restrict_mutex:要初始化的互斥量
        restrictattr:一般为nullptr
    返回值:成功返回0,失败返回错误码。

声明一把锁:pthread_mutex_t lock;

互斥量销毁:

        使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁。

        不要销毁一个已经加锁的互斥量

        已经销毁的互斥量,要确保后面不会有线程再尝试加锁

int pthread_mutex_destroy(pthread_mutex_t *mutex);

互斥量加锁和解锁:

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);

返回值:成功返回0,失败返回错误号 

在加锁时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量, 那么pthread_mutex_lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

改善上面的售票代码:

代码7:

# include <iostream>
# include <cstdio>
# include <string>
# include <cstring>
# include <memory> 
# include <vector>
# include <unistd.h>
# include <pthread.h>


#define NUM 4

// pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;

// 共享资源
int tickets = 10000;

class ThreadData
{
public:

    ThreadData(const std::string &threadname, pthread_mutex_t *mutex_p)
        :_threadname(threadname)
        ,_mutex_p(mutex_p)
    {}

    ~ThreadData(){}

public:
    std::string _threadname;
    pthread_mutex_t *_mutex_p;
};

void *thread_run(void *args)
{
    
    ThreadData *td = static_cast<ThreadData*>(args);
    while(true)
    {
        pthread_mutex_lock(td->_mutex_p);
        if(tickets > 0)
        {
            usleep(1234); // 1s=1000ms=1000 000us
          
            std::cout  << td->_threadname << " 正在进行抢票:" << tickets << std::endl;
            // 用这段时间来模拟真实抢票花费的时间
            tickets--;
            pthread_mutex_unlock(td->_mutex_p);
        }
        else 
        {
            pthread_mutex_unlock(td->_mutex_p);
            break; 
        }  
                

        // 抢完票就完了吗? 模拟抢完票之后的流程
        usleep(1000);
    }
    return nullptr;
}

int main()
{
    pthread_mutex_t lock;
    pthread_mutex_init(&lock, nullptr);
    std::vector<pthread_t> tids(NUM);
    for(int i = 1; i <= NUM; i++)
    {
        char namebuffer[64];
        snprintf(namebuffer, sizeof(namebuffer), "thread %d", i);
        ThreadData *td = new ThreadData(namebuffer, &lock);
        pthread_create(&tids[i-1], nullptr, thread_run, td);
    }

    for(const auto &tid:tids)
    {
        pthread_join(tid, nullptr);
    }

    pthread_mutex_destroy(&lock);

    return 0;
}

运行结果(此时就没有问题了):

如何看待锁?

  • 锁本身就是一个共享资源,全局变量是要被锁保护的
  • pthread_mutex_lock/pthread_mutex_unlock,加锁的过程是安全的、原子的
  • 如果申请锁成功,继续向后执行。申请暂时没有成功,执行流会阻塞
  • 谁持有锁,谁进入临界区
  • 多线程中的某个进程申请锁成功,在访问临界资源时也是可以被切走的;即使被切走,其他线程依旧无法申请锁成功,直到我这个线程释放锁(解锁)
  • 在使用锁时,尽量使临界区的粒度(锁中间保护的代码量)比较小

3.4 可重入和线程安全

3.4.1 概念

  • 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作, 并且没有锁保护的情况下,会出现该问题。
  • 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们 称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重 入函数,否则,是不可重入函数。

3.4.2 线程安全/不安全情况

不安全情况:

  • 不保护共享变量的函数
  • 函数状态随时被调用,状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全的函数

安全情况:

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

3.4.3 可重入/不可重入情况

可重入情况:

  • 不使用全局变量或静态变量
  • 不使用用malloc或者new开辟出的空间
  • 不调用不可重入函数
  • 不返回静态或全局数据,所有数据都有函数的调用者提供
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

不可重入情况:

  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
  • 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
  • 可重入函数体内使用了静态的数据结构

3.4.4 可重入与线程安全联系/区别

联系:

  • 函数是可重入的,那就是线程安全的
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的

区别:

  • 可重入函数是线程安全函数的一种
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的
  • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的

4.锁 

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

4.1 死锁的四个必要条件

  • 互斥条件:一个资源每次只能被一个执行流使用
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件: 一个执行流已获得的资源,在末使用完之前,不能强行剥夺
  • 循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系

4.2 避免死锁的方法

  • 死锁检测算法(了解)
  • 银行家算法(了解)
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值