如何理解 Linux 下的多线程、锁以及原子操作
引言
对于一个操作系统来说,提高资源利用率和系统吞吐量是其主要目的,而提高资源利用率和系统吞吐量的主要手段,就是提高操作系统并发执行的能力,什么是并发呢,简单来说,就是操作系统在同一个时间段内执行多个任务。或许还会经常听到一个名词——并行,二者听起来相似,但是差别很大。并行是指操作系统在同一个时刻执行多个任务,而一个 CPU 在一个时刻显然只能执行一个任务,因此,只有多 CPU 的环境下才可能实现并行,所以,我们在此主要讨论并发相关的问题。那么,在实际情况中,系统如何来实现并发呢?下面我们先从理论角度出发,讨论这个问题。
进程、线程、进程同步和死锁
进程
对于一个程序来说,最有可能的一个逻辑结构,便是获取输入,处理数据,输出结果。假设我们要获取的输入在万级以上,而 CPU 速度较慢。如果没有获取到用户的输入,那么程序就没有办法处理数据,如果程序没有处理数据,那么程序就没有办法输出。那么,假如我们要执行多次这个程序,我们该如何提升它的执行速度呢?
如果对计算机组成原理熟悉的话,计算机组成原理中有一个概念便是流水,通过将一个流程分为若干个小的步骤,从而在一个时钟周期内并发执行。如同车间的流水线一样,每一道工序其实并没有必要等待前一个完成之后再开始执行,而是只要达到其可以执行的前提条件,就可以开始执行了。
不妨类比一下,程序在本质上就是一个对数据加工的过程。我们也没有必要等待输入全部完成之后再处理数据,也没有必要等待数据全部处理完之后再输出。但是如果仅仅只有一个程序的话,显然不能实现如此的效果。不过,我们也可以将程序分为若干个部分,产生的结果就是进程。
我们将输入的指令分至进程 1,处理数据的指令分至进程 2,输出的指令分至进程 3。如此,我们便可以每隔一段时间(时间片)分别调用这些进程,从宏观上来说,它们是同时执行的。每个进程只要满足可执行条件就可以执行,从而减少时间的浪费,提高程序的运行效率,提高系统的吞吐量。以下是我认为比较好的一个进程的定义:
进程是具有独立功能的程序在一个数据集合上运行的过程,它是系统进行资源分配和调度的一个单位
线程
大家或许还听到过一个概念叫做线程,那么,线程和进程的区别是什么呢?
我们先来探讨一下进程的执行过程。任何程序都要在 CPU 中运行,但是在执行的之前,系统必须进行一些配置(寄存器中保存什么值、从哪一个指令开始等)。当一个进程执行完毕,或分配给它的执行时间(时间片)用完之后,我们需要执行下一个进程,此时就需要进行进程调度,把之前进程(只是时间片用完,但并没有执行完毕)的运行配置保存下来,把下一个进程的运行配置加载至 CPU,开始执行。显然此过程也是时间的消耗,那么我们能否避免,或者减少这样时间的消耗呢?
我们的线程就应允而生了。线程,本质上,也是对程序的一次分解,只不过分解的方式有所不同。在将程序分解为进程时,我们就相当于把程序完全切开了,彼此除了逻辑上之外,基本互不相关,每个进程都有它自身的程序和数据等相关信息,也正因如此,我们在调度进程时,需要耗费大量的时间。如果将程序分解为线程,其实并没有对程序 “动手”,而是从逻辑上把它分为了若干个部分,每个线程只是保存它要执行的那部分指令在程序中从哪里开始,到哪里结束等信息。
综上所述,线程相较于进程,显然是非常轻的(因为进程还保存了它的程序等资源,而线程并没有)。因此当调度线程时,并不需要大刀阔斧,只需快速的将信息保存至 CPU 即可,从而加快了CPU调度时的时间。但线程也存在一些问题,那就是被分割程序的资源是所有线程共享的,如果使用不当,那就可能会出现错误。
关于进程和线程,我们可以使用下图来简单理解:
进程同步
以上我们讨论的都是理想的情况,但是在大多数情况下,都会出一些问题。
-
例如,我们将程序的处理(计算)部分,分解为若干个进程或线程,那么当我们的程序在调度时,它们的顺序是并不确定的。大家知道,程序的三大执行结构:顺序、循环以及分支,其中顺序结构显然是最常见的,我们的程序,理论上应该是从上到下依次执行的。那么如果调度顺序不确定,那么显然就破坏了程序的执行结构,那么最直观的就是会导致结果的不正确。 -
另外一个方面是,每个进程可能都会访问同一个变量,当进程在离开CPU之前(并没有执行完成),这个变量运行良好,但当这一进程再次运行,访问这个变量时,却发现早已 “物是人非” 了。因为,在它离开的这段日子里,此变量被其它的进程 “糟蹋了一番”,这种情形即使是人类,也会迷茫吧。如果就在这样的情况继续工作,那么只会是一错再错,这显然不是我们想要的,这里带大家简单体验一下:
我们使用 10 个线程来计数,每个线程从 1 到 100000,因此最终结果应当显示为 1000000,但结果并不如愿,由于大家都访问同一个变量 count,导致每一个线程在执行时,count 与原来的内容均不一致。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#define THREAD_COUNT 10
void* thread_callback(void *arg) {
int *pcount = (int *)arg;
int i = 0;
while (i++ < 100000) {
++(*pcount);
usleep(1);
}
}
int main() {
pthread_t threadid[THREAD_COUNT] = {0};
int i = 0, count = 0;
for (i = 0; i < THREAD_COUNT; ++i) {
pthread_create(&threadid[i], NULL, thread_callback, &count);
}
for (i = 0; i < 100; ++i) {
printf("count: %d\n", count);
sleep(1);
}
return 0;
}
执行结果如下:
那么我们如何解决这个问题呢?
进程同步的主要目的,其实就是通过一定的手段来控制各个进程的执行顺序,确保大家访问的资源(临界资源)在进程离开期间不要被其它进程“糟蹋”,如果使用,也要等之前访问这个资源的进程使用完毕之后再使用,如此才能保证程序的正确执行。
那么我们如何实现进程同步呢?
- 硬件
- 关中断
- Test-and-Set 指令
- Swap 指令
- 信号量
- 整型信号量
- 记录型信号量
- AND 型信号量
- 信号量集
- 管程机制
由于进程同步的方法较多,且比较不好理解,因此不打算在这里详细讲解,如果有需要,之后将会再写一篇博客,来说明这些内容
死锁
如果进程同步使用不当的话,那将产生严重的问题——死锁。
死锁,其实本质上与锁并没有关系,其含义是程序没有办法自动解开死循环。何处来的死循环呢?其实就是每个进程所需要的资源被其它资源占用,从而导致每个进程都没有办法执行,从而使系统的吞吐量直线下降。那么我们如何解决死锁呢?
简单来说,我们可以将它扼杀在摇篮之中,也可以对死锁进行预防。既然程序没有办法自动解开死循环,那么,我们可以实时检查是否有死锁的出现,然后终止某些进程,释放资源,让其它进程可以运行,从而解除死锁。由于与死锁相关的内容也很多,因此也不在此详细说明。
Linux 下的进程与线程
以上均属于理论部分,下面我们来看一看 Linux 环境下的进程与线程的内容。在 Linux 下,一个程序的运行就是一个进程的运行,我们在编写 C 语言程序时,系统提供了两个函数来创建进程与线程,分别是 fork() 和 pthread_create() 。
-
fork 这个单词的意思是克隆,也就是将当前的程序再克隆一份出来,从而实现进程的创建,当执行完 fork 之后,系统中会有两个完全一样的进程在运行,调用 fork 函数的为父进程,fork 产生出来的是子进程,它们具有相同的运行代码,那么如何将他们分开呢?根据 fork 的返回值,对于子进程,fork 函数会返回 0,对于父进程,fork 函数会返回子进程的进程 id。所以 fork 函数是调用一次(父进程),返回两次(父进程和子进程)。那么程序就可以通过返回值来判断,如果是子进程就执行某一代码块,如果是父进程则执行另一个代码块。 -
pthread_create 显然就是线程创建的意思,调用它,我们需要指定回调函数,也就是线程开始执行的内容,调用后,会返回一个线程id,它是我们控制线程的主要手段,上述实例中我们使用的就是线程。
不论是 fork 还是 pthread,它们都有可能会产生死锁问题,在 fork 中还有可重入与不可重入的概念,但在此只是简要介绍一下,下面我们主要讨论 Linux 下的锁以及原子操作的内容,均与 pthread 相关。
Linux 下的锁、原子操作
Linux 下调用线程是,正常的情况是,每一个线程执行完操作之后,再让下一个线程执行相应的操作。而不正常的情况是,在一个线程的所有操作还没有执行完成时,就切换到下一个线程执行相应的操作,完成之后再执行上一个线程还没有执行完的操作,从而在结果上产生了错乱。对此,我们可以采用为临界资源加锁与原子操作的方式来解决。
在 Linux 中,锁主要有两类,分别为互斥锁和自旋锁:
- 互斥锁:mutex,在执行操作之前加锁,执行完成操作之后解锁,在上锁期间,其它的线程是不能访问临界资源的,此时,试图访问临界资源的线程会引起线程切换,适用于锁的内容较多的情况
- 自旋锁:spinlock,在使用上类似于 mutex ,差别是,未能成功访问临界资源的线程会不停的尝试去访问该临界资源,直到获取到该临界资源为止,适用于锁的内容较少的情况(减少线程切换的代价)
原子操作,本质上,是使用一条 cpu 指令,实现若干条 cpu 指令,从而使若干操作不可能被分割,只能同时执行
下面我们来看一下如何使用互斥锁来解决上述程序的问题:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#define THREAD_COUNT 10
pthread_mutex_t mutex;
void* thread_callback(void *arg) {
int *pcount = (int *)arg;
int i = 0;
while (i++ < 100000) {
pthread_mutex_lock(&mutex);
++(*pcount);
pthread_mutex_unlock(&mutex);
usleep(1);
}
}
int main() {
pthread_t threadid[THREAD_COUNT] = {0};
pthread_mutex_init(&mutex, NULL);
int i = 0, count = 0;
for (i = 0; i < THREAD_COUNT; ++i) {
pthread_create(&threadid[i], NULL, thread_callback, &count);
}
for (i = 0; i < 100; ++i) {
printf("count: %d\n", count);
sleep(1);
}
return 0;
}
编译指令如下:
$ gcc -o lock lock.c -lpthread
执行结果如下:
总结
本篇文章主要讨论了操作系统中进程与线程的相关概念,也讨论了在 Linux 系统中的进程与线程,演示了如何在程序中创建一个线程,并使用锁来解决线程执行中出现的问题。上述描述并不一定正确,主要目的是帮助大家更好的理解操作系统中进程与线程的概念。
下面是一些有关 Linux 下线程相关函数的总结:
函数 | 功能 |
---|
pthread_t | 线程 ID | pthread_create | 创建线程 | pthread_mutex_t | 互斥锁类型 | pthread_mutex_init | 初始化互斥锁 | pthread_spinlock_t | 自旋锁类型 | pthread_spin_init | 初始化自旋锁 |
后记
推荐一个零声学院免费公开课程,个人觉得老师讲得不错,分享给大家:[Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,点击立即学习:
此文章主要用来作为课程学习过程中的总结,不仅有具体的实战代码,还会联系大学中的计算机知识体系,大家如果有什么问题也可以在评论区留言,我会尽力帮大家解决问题,如果觉得对大家有帮助的话,希望多多点赞、转发,谢谢!
|