线程及线程安全

2021-08-19  本文已影响0人  希尔罗斯沃德_董
                                          参考自《程序员的自我修养》

线程基础

什么是线程

线程是程序执行的最小单位。一个标准的线程由线程ID、当前指令指针PC、寄存器集合和堆栈组成。通常来说,一个进程有一个或多个线程组成。各个线程之间共享程序的内存空间(包括代码段、数据段、堆等)及一些进程级的资源。一个经典的线程和进程的关系如下图所示:


进程内的线程.jpg
多线程

大多数应用程序是多线程的,多个线程可以互不干扰地并发执行,并共享进程的全局变量和堆的数据。那么,什么情况下使用多线程呢?

线程访问权限

线程可以访问进程内存里的所有数据(包括全局变量、堆上的数据、函数里的静态变量、程序代码和代打开文件等),如果知道其他线程的堆栈地址,甚至也可以访问,但是在实际应用中线程也拥有自己的私有存储空间,包括以下几个方面:

线程调度和优先级

当线程数量小于处理器储量(操作系统支持多处理器),线程的并发是真正的并发。但对于线程数量大于处理器的数量,线程并发会受到一定的阻碍,因此此时至少由一个处理器会运行多个线程。一个处理器运行多个线程,操作系统会让这些多线程程序轮流执行,每次仅执行一小时间(通常几十到几百毫秒),这样每个线程就看起来在同时执行。这样一个不断在处理器上切换线程的行为称之为线程调度(Thread Schedule)。在线程调度中通常有至少三种状态,分别是:

线程安全

前面我们知道,多线程程序中,多线程共享进程空间。进程中的全局变量和堆数据随时都可能被其他线程修改,因此多线程的程序中在并发时保持数据的一致性非常重要。这就引出了线程安全问题得讨论。

竞争与原子操作

多个线程同时访问一个共享数据,就有可能造成恶劣的后果。就比如下面的一个例子:
假设有个变量i=1,线程1对i++:

i++

线程2对i--:

i--;

在很多体系结构中,i++的实现如下:

(1)读取i到某个寄存器X;
(2)X++;
(3)将X的内容存回i。

i--也是同理,假设i进程2的寄存器是X2。由于线程1和线程2并发执行,有可能会出现如下情况,当线程1进行(2)步还没到(3)步时,这时候线程2进来,也取走i存入X2,并进行X2--,而此时i依然是i=1,X=2,X2=0,接下来线程1进行(3)步骤,此时i=X=2,但是这回线程2也来了,接着也进行1=X2的操作,此时i=0。而从代码逻辑上看,两个线程执行完毕之后i的值应该为1,但这里得到的是0,实际上两个线程同时执行的话,i的结果可能是0,也可能是1或2。

很明显,自增(++)操作在多线程环境下会出现错误是因为这个操作被编译为汇编代码之后不止一条指令,因此在执行的时候可能执行一半就被系统打断,去执行别的代码。我们把单指令的操作成为原子操作(Atomic),因为无论如何,单条指令的执行是不会被打断的。

原子操作虽然一定程度上能保证线程安全。但是原子操作只适用于比较简单的场合,在复杂的场合下,比如我们要保证一个复杂的数据结构更改的原子性,原子操作就显得力不从心了。这里就可以用到通用的手段:锁。

同步与锁

为了避免多个线程同时读写一个数据而产生不可预料的后果,我们要将各个线程对同一个数据的访问同步(Synchronization)。所谓同步,就是指一个线程访问数据未结束的时候,其他线程不得对同一个数据进行访问。如此,对数据的访问被原子化了。

同步最常用的方式是使用锁(Lock)。锁是一种非常强的机制,每一个线程在访问数据或资源之前首先试图获取锁,并在访问结束之后释放锁。在锁已经被占的时候试图获取锁时,线程会等待,直到锁被其他线程释放重新可用。常用的锁有如下几种:

(1)将信号量的值减1;
(2)如果信号量的值小于0,则进入等待状态,否则继续执行。访问资源之后,线程释放信号量,进行如下操作。
(3)将信号量的值加1。
(4)如果信号量的值小于1,唤醒一个等待中的线程。

可重入(Reentrant)与线程安全

一个函数被重入,表示这个函数没有执行完成,由于外部因素或内部调用,又一次进入该函数执行。一个函数要被重入,只有两种情况:
(1)多个线程执行这个函数。
(2)函数自身调用自身(比如递归调用)。
一个函数被称为可重入的,表明该函数被重入之后不会产生任何不良后果。举个例子,如下这个函数:

int add(int x){
    return x + x;
}

一个函数要成为可重入的,必须由如下几个特点:

三种多线程模型

线程的并发执行时多处理器或者操作系统调度来实现的。大多数操作系统,内核线程有多处理器或调度来实现并发。然而用户实际使用的线程并不是内核线程,而是存在于用户态的用户线程。用户态的线程并不一定在操作系统内核里有对应同等数量的内核线程,,对用户来说如果有三个线程同时执行,对内核来说很可能只有一个线程。

一对一模型

一个用户使用的线程唯一对应一个内核使用的线程(反过来不一定)。这样用户线程就具有了和内核线程一致的优点。

多对一模型

多对一模型将多个用户线程映射到一个内核线程上,线程间的切换由用户态的代码来进行。因此相对于一对一模型,多对一模型的线程切换要快得多。

多对多模型

多对多模型结合了多对一模型和一对一模型的特点,将多个用户线程映射到少数但不止一个内核线程上。在多对多模型中,一个线程阻塞不会使得所有线程的用户线程阻塞,因为此时还有别的线程可以被调度来执行。另外,多对多模型对用户线程的数量也没什么限制,在多处理器系统上,多对多模型的线程也能得到一定的性能提升,不过提升的幅度不如一对一模型的高。

上一篇 下一篇

猜你喜欢

热点阅读