Linux线程

前言

并行和并发

并发是指同一时刻只能有一条指令执行,但多个进程指令被快速轮换执行,使得在宏观上有多个进程被同时执行的效果–宏观上并行,针对单核处理器。

并行就是同一时刻,有多条指令在多个处理器上同时执行,针对多核处理器

并发关系有两种,分别是同步和互斥:

  • 进程间相互排斥的使用临界资源的现象,就叫互斥。
  • 对于同步而言,进程之间的关系不是相互排斥临界资源的关系,而是相互依赖的关系。就是前一个进程的输出作为后一个进程的输入,当第一个进程没有输出时第二个进程必须等待。具有同步关系的一组并发进程相互发送的信息称为消息或事件。(彼此有依赖关系的调用不应该同时发生,而同步就是阻止那些“同时发生”的事情)。和同步相对的就是异步,就是彼此独立,在等待某事件的过程中继续做自己的事,不需要等待这一事件完成后再工作。线程就是实现异步的一个方式。

临界资源:是一次仅允许一个进程使用的共享资源。各进程采取互斥的方式,实现共享的资源称作临界资源。
临界区: 每个线程内部,访问临界资源的代码,就叫做临界区。


一、Linux线程概念

1.什么是线程

  • 线程是一个进程内部的控制序列
  • 一切进程至少都有一个执行线程
  • 线程在进程内部运行,本质是在进程地址空间内运行
  • 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。

线程的优势:

  • 创建一个新线程的代价要比创建一个新进程小得多
  • 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
  • 线程占用的资源要比进程少很多
  • 能充分利用多处理器的可并行数量
  • 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
  • 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
  • I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

线程的缺陷:

  • 性能损失: 一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
  • 健壮性降低: 编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
  • 缺乏访问控制: 进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
  • 线程异常: 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃,线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出

2.进程和线程的区别

  • LWP:light weight process 轻量级的进程,本质仍是进程(在Linux环境下)
  • 进程:独立地址空间,拥有PCB
  • 线程:也有PCB,但没有独立的地址空间(共享)
  • 区别:在于是否共享地址空间。 独居(进程);合租(线程)
  • Linux下: 线程:最小的执行单位,调度的基本单位
  • 进程:最小分配资源单位,可看成是只有一个线程的进程

对于进程来说,相同的地址(同一个虚拟地址)在不同的进程中,反复使用而不冲突。原因是他们虽虚拟址一样,但,页目录、页表、物理页面(页表的三级映射)各不相同。相同的虚拟址,映射到不同的物理页面内存单元,最终访问不同的物理页面。But!两个线程具有各自独立的PCB,但共享同一个页目录,也就共享同一个页表和物理页面。所以两个PCB共享一个地址空间。

线程间的共享资源:

  1. 文件描述符表
  2. 每种信号的处理方式
  3. 当前工作目录
  4. 用户ID和组ID
  5. 内存地址空间 (.text/.data/.bss/heap/共享库)

线程间的非共享资源:

  1. 线程id
  2. 所属线程的栈区
  3. 程序计数器、栈指针以及函数运行使用的寄存器
  4. errno变量
  5. 信号屏蔽字
  6. 调度优先级

二、Linux线程控制

1.POSIX线程库

对于Linux而言,没有真的线程,只是一种轻量级的进程。所以Linux的线程库并不是操作系统提供的,而是一个第三方库。

  • 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的。
  • 要使用这些函数库,要通过引入头文
  • 链接这些线程函数库时要使用编译器命令的“-lpthread”选项

2.创建线程

代码如下:

//功能:创建一个新的线程
//原型
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);

//参数
//thread:返回线程ID

//attr:设置线程的属性,attr为NULL表示使用默认属性

//start_routine:是个函数地址,线程启动后要执行的函数

//arg:传给线程启动函数的参数

//返回值:成功返回0;失败返回错误码

错误检查:

  • 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
  • pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回.
  • pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值判定,因为读取返回值要比读取线程内的errno变量的开销更小。

3.线程ID及其空间地址布局

  • pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
  • 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
  • pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
  • 线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID。

因为线程相关函数是由第三方库实现的,所以线程的地址空间也包含在动态库内:
Linux线程_第1张图片
这里的线程ID其实是一个地址,指向了存储线程信息那一块地址的起始位置。通过对指针的解引用就可以找到存储线程信息的结构体struct_pthread。

4.线程终止

如果需要只终止某个线程而不终止整个进程,可以有三种方法:

  1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit(相当于整个进程就退出了)。
  2. 线程可以调用pthread_ exit终止自己。
  3. 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。

pthread_exit函数:

//功能:线程终止
//原型
void pthread_exit(void *value_ptr);

//参数
//value_ptr:value_ptr不要指向一个局部变量。
//返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)

pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。

pthread_cancel函数:

//功能:取消一个执行中的线程
//原型
int pthread_cancel(pthread_t thread);

//参数
//thread:线程ID
//返回值:成功返回0;失败返回错误码

5.线程等待

已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。创建新的线程不会复用刚才退出线程的地址空间。基于以上两点,需要进行线程等待。

//功能:等待线程结束
//原型
int pthread_join(pthread_t thread, void **value_ptr);

//参数
//thread:线程ID
//value_ptr:它指向一个指针,后者指向线程的返回值
//返回值:成功返回0;失败返回错误码

调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:

  1. 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
  2. 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED。
  3. 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
  4. 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。

6.分离线程

默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。

如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。这就是分离线程

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

//分离目标线程
pthread_detach(pthread_t thread);

//分离线程自身
pthread_detach(pthread_self());

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

三、线程互斥

互斥量: 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。

但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。

先来看下面这个抢票代码:

#include
#include
#include

int tickets = 1000;

void* TicketsGarb(void* msg) {
    int num = (int)msg;

    while (1) {
    //当票数不为零时,才进行抢票操作,否则就退出
        if (Tickets > 0) {
            usleep(1000);
            printf("num %d get a ticket, remains %d\n", num, --tickets);
        }
        else {
            break;
        }
    }
    printf("num %d quit!\n", num);
}
int main() {
    pthread_t tid[5];

    for (int i = 0; i < 5; ++i) {
        pthread_create(tid + i, NULL, TicketsGarb, (void*)i);
    }



    for (int i = 0; i < 5; ++i) {
        pthread_join(tid[i]);
    }
    return 0;
}

结果如下:
Linux线程_第2张图片
可以发现,虽然我们设置了if条件,但是依然出现了错误的结果,为什么呢?

因为if 语句判断条件为真以后,代码可以并发的切换到其他线程,usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段,–ticket 操作本身就不是一个原子操作。

来看一下前置–tickets的汇编操作,
Linux线程_第3张图片

–操作并不是原子操作,而是对应三条汇编指令:

  1. load :将共享变量ticket从内存加载到寄存器中
  2. update : 更新寄存器里面的值,执行-1操作
  3. store :将新值,从寄存器写回共享变量ticket的内存地址

那么如何解决这样的问题呢?

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

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

1.互斥量接口

互斥量初始化:

方法1,静态分配:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

方法2,动态分配:

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

//参数:
//mutex:要初始化的互斥量
//attr:NULL

销毁互斥量:

//销毁互斥量
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_ lock 时,可能会遇到以下情况:

  1. 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  2. 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

销毁互斥量时应当注意:

  1. 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁
  2. 不要销毁一个已经加锁的互斥量
  3. 已经销毁的互斥量,要确保后面不会有线程再尝试加锁

加上互斥量后,再来看一下我们的抢票代码:

#include
#include
#include

int tickets = 1000;

pthread_mutex_t lock;

void* TicketsGarb(void* msg) {
    int num = (int)msg;

    while (1) {
        pthread_mutex_lock(&lock);
        if (Tickets > 0) {
            usleep(1000);
            printf("num %d get a ticket, remains %d\n", num, --tickets);
            pthread_mutex_unlock(&lock);
        }
        else {
            pthread_mutex_unlock(&lock);
            break;
        }
    }
    printf("num %d quit!\n", num);
}
int main() {
    pthread_t tid[5];
    pthread_mutex_init(&lock, NULL);

    for (int i = 0; i < 5; ++i) {
        pthread_create(tid + i, NULL, TicketsGarb, (void*)i);
    }



    for (int i = 0; i < 5; ++i) {
        pthread_join(tid[i]);
    }

    pthread_mutex_destroy(&lock);
    return 0;
}

结果如下:
Linux线程_第4张图片

2.互斥实现原理

为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。

现在我们把lock和unlock的伪代码改一下:
Linux线程_第5张图片
其实,互斥量就像一个令牌一样,只有令牌持有者才能进行临界资源的访问,因为令牌只有一个,当其访问完成后,就要归还令牌。

3.可重入和线程安全

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

可重入: 同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

常见的线程不安全的情况:

  1. 不保护共享变量的函数
  2. 函数状态随着被调用,状态发生变化的函数
  3. 返回指向静态变量指针的函数
  4. 调用线程不安全函数的函数

常见线程安全的情况:

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

常见不可重入的情况:

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

常见可重入的情况:

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

可重入与线程安全联系:

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

可重入与线程安全区别:

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

四、常见锁的概念

1.死锁

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

死锁四个必要条件:

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

想要避免死锁,就要破坏形成死锁的条件:

  1. 加锁顺序一致(破坏循环等待条件)
  2. 避免锁未释放的场景(破坏不剥夺条件)
  3. 资源一次性分配(破坏请求与保持条件)

避免死锁的算法有死锁检测算法,银行家算法。

2.线程同步

条件变量:

  • 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
  • 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。

同步概念与竞态条件:

  • 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
  • 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。

条件变量函数的初始化:

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);

互斥量的作用:

  • 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
  • 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。

条件变量使用方式:

//等待条件代码:
pthread_mutex_lock(&mutex);
while (条件为假)
pthread_cond_wait(cond, mutex);
//否则修改条件
pthread_mutex_unlock(&mutex);

//给条件发送信号代码:
pthread_mutex_lock(&mutex);
//设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);

五、生产者消费者模型

1.什么是生产者消费者模型

假设有两个进程(或线程)A、B和一个固定大小的缓冲区,A进程生产数据放入缓冲区,B进程从缓冲区中取出数据进行计算,这就是一个简单的生产者-消费者模型。这里的A进程相当于生产者,B进程相当于消费者。

Linux线程_第6张图片

2.为何要使用生产者消费者模型

生产者消费者模式就是通过一个缓冲区来解决生产者和消费者的强耦合问题。

生产者和消费者彼此之间不直接通讯,而通过阻塞队列来进行通讯,所以生产者生产完数据之后不用等待消费者处理,直接扔给阻塞队列,消费者不找生产者要数据,而是直接从阻塞队列里取,阻塞队列就相当于一个缓冲区,平衡了生产者和消费者的处理能力。这个阻塞队列就是用来给生产者和消费者解耦的。

其优势在于:解耦、支持并发、支持忙闲不均

3.生产者消费者模型的特点

  • 保证生产者不会在缓冲区满的时候继续向缓冲区放入数据,而消费者也不会在缓冲区空的时候,消耗数据。
  • 当缓冲区满的时候,生产者会进入休眠状态,当下次消费者开始消耗缓冲区的数据时,生产者才会被唤醒,开始往缓冲区中添加数据;当缓冲区空的时候,消费者也会进入休眠状态,直到生产者往缓冲区中添加数据时才会被唤醒。

六、POSIX信号量

POSIX信号量和SystemV信号量作用相同,都是用于同步操作,达到无冲突的访问共享资源目的。 但POSIX可以用于线程间同步。

初始化信号量:

#include 
int sem_init(sem_t *sem, int pshared, unsigned int value);

//参数:
//pshared:0表示线程间共享,非零表示进程间共享
//value:信号量初始值

销毁信号量:

int sem_destroy(sem_t *sem);

等待信号量:

//功能:等待信号量,会将信号量的值减1
int sem_wait(sem_t *sem); //P()

释放信号量:

//功能:发布信号量,表示资源使用完毕,可以归还资源了。将信号量值加1。
int sem_post(sem_t *sem);//V()

七、线程池

1.什么是线程池

一种线程使用模式。线程过多会带来调度开销,进而影响缓存局部性和整体性能。而线程池维护着多个线程,等待着监督管理者分配可并发执行的任务。这避免了在处理短时间任务时创建与销毁线程的代价。线程池不仅能够保证内核的充分利用,还能防止过分调度。可用线程数量应该取决于可用的并发处理器、处理器内核、内存、网络sockets等的数量。

2.线程池的使用场景

  1. 需要大量的线程来完成任务,且完成任务的时间比较短。 WEB服务器完成网页请求这样的任务,使用线程池技术是非常合适的。因为单个任务小,而任务数量巨大,你可以想象一个热门网站的点击次数。 但对于长时间的任务,比如一个Telnet连接请求,线程池的优点就不明显了。因为Telnet会话时间比线程的创建时间大多了。
  2. 对性能要求苛刻的应用,比如要求服务器迅速响应客户请求。
  3. 接受突发性的大量请求,但不至于使服务器因此产生大量线程的应用。突发性大量客户请求,在没有线程池情况下,将产生大量线程,虽然理论上大部分操作系统线程数目最大值不是问题,短时间内产生大量线程可能使内存到达极限,出现错误。

八、其他的各种常见锁

悲观锁: 在每次取数据时,总是担心数据会被其他线程修改,所以会在取数据前先加锁(读锁,写锁,行锁等),当其他线程想要访问数据时,被阻塞挂起。

乐观锁: 每次取数据时候,总是乐观的认为数据不会被其他线程修改,因此不上锁。但是在更新数据前,会判断其他数据在更新前有没有对数据进行修改。主要采用两种方式:版本号机制和CAS操作。

CAS操作: 当需要更新数据时,判断当前内存值和之前取得的值是否相等。如果相等则用新值更新。若不等则失败,失败则重试,一般是一个自旋的过程,即不断重试。

自旋锁: 和挂起等待锁稍有不同,自旋锁会不断地询问信息。

你可能感兴趣的:(Linux,linux,java,运维)