Linux并发编程中的线程与线程安全

1. 线程基础:

1.1 什么是线程:

线程(Thread),也称“轻量级进程”(Lightweight Process,LWP),是程序执行流的最小单位。
一个标准的线程由 线程ID、当前指令指针(PC)、寄存器集合 和 栈 组成。
通常意义上,一个进程由一个或多个线程组成,各个线程之间共享程序的 内存空间(包括 代码段、数据段、堆 等)及一些进程级的资源(如打开文件和信号)

一个经典的线程与进程的关系如图所示:

在这里插入图片描述

相比于单线程,使用多线程的原因有如下几点:

  1. 某个操作可能会陷入长时间等待,等待的线程会进入睡眠状态,无法继续执行。多线程执行可以有效利用等待的时间。典型的例子是等待网络响应,这可能要花费数秒甚至数十秒;
  2. 某个操作(常常是计算)会消耗大量的时间,如果只有一个线程,程序和用户之间的交互会中断。多线程可以让一个线程负责交互,另一个线程负责计算;
  3. 程序逻辑本身就要求并发操作,例如一个多端下载软件(例如Bittorrent);
  4. 多CPU或多核计算机,本身具备同时执行多个线程的能力,因此单线程程序无法全面的发挥计算机的全部计算能力;
  5. 相对于多进程应用,多线程在数据共享方面效率更高。

1.2 线程调度:

当一个处理器上运行多个线程时(线程数量大于CPU数量),操作系统会让这些多线程程序轮流执行,每次执行一小段时间。操作系统不断的在处理器上切换不同线程的行为,称为 “线程调度”(Thread Schedule)

(所谓“线程调度”,就是指操作系统让多个线程在一个处理器行轮流执行的过程。)

在线程调度中,线程通常拥有至少三种状态:
(1)运行(Running): 此时线程正在执行;
(2)就绪(Ready): 此时线程可以立刻运行,但CPU已经被占用;
(3)等待(Waiting): 此时线程无法运行,正在等待某一事件发生(通常是I/O或同步)。

处于运行中的线程拥有一段可以执行的事时间,这段时间称为 “时间片”(Time Slice),当时间片用尽的时候,该线程就进入 就绪 状态;
如果在时间片用尽之前进程就开始等待某事件,则它将进入 等待 状态。

每当一个线程离开运行状态时,调度系统就会选择一个其他的就绪线程继续执行。

线程状态切换示意图:

在这里插入图片描述

1.2.1 线程调度算法:

线程调度自从多任务操作系统问世以来就不断的被提出不同的方案和算法,现在主流的调度方式尽管各不相同,但都带有 “优先级调度”(Priority Schedule)“轮转法”(Round Robin) 的痕迹。

  1. 轮转法:

所谓轮转法,就是上面提到的让各个线程轮流执行一小段时间的方法。
这决定了线程之间 交错执行 的特点。

  1. 线程优先级:

优先级调度决定了线程按照什么样的顺序轮流执行,在具有优先级调度的系统中,每个线程都拥有各自的线程优先级(Thread Priority)。具有高优先级的线程会更早的执行,低优先级的线程常常要等到系统中已经没有比它更高优先级的线程需要执行时,才能够得到执行。

线程的优先级允许程序员调用API函数指定,同时系统会根据线程的表现自动调整优先级,一般来说 “CPU密集型线程”(CPU Bound Thread) (这种线程一般需要占用大量的CPU时间来进行计算) 要比 “IO密集型线程”(IO Bound Thread) 更容易得到操作系统的调度,这是因为 CPU密集型线程一般都会把时间片用完,而 IO密集型线程会频繁的进入等待状态。

线程饿死: 在优先级调度下,存在一种 **饿死(Starvation)**现象,即总是有高优先级的线程需要执行,低优先级的线程长时间得不到调度。 为了避免饿死现象,操作系统会逐步提升那些等待了过长时间的得不到执行的线程的优先级。 这样,主要一个线程等待足够长的时间,其优先级一定会提高到足够让它执行的程度。

(小结:尽管线程的调度算法多种多样,但一般都基于两个思想:优先级调度和轮转法。轮转法(时间片)决定了线程之间必须交替执行,优先级调度决定了多个线程间需要按照什么顺序轮流执行。
而改变一个线程的优先级又有三种方式:程序员指定、系统根据计入等待状态的频繁程度自动调整、长时间得不到调度被系统提升。)

1.2.2 可抢占线程和不可抢占线程:

所谓“可抢占线程”和“不可抢占线程”,指的是一个线程在用尽时间片 之后 是否允许被强行剥夺继续执行的权利。

现代操作系统一般都是基于“可抢占线程”的模式。

不可抢占线程模式的优点是:线程调度的时机是确定的,线程调度只会发生在线程主动放弃执行或者线程等待某事件的时候。这样就可以避免一些因为抢占式线程里调度时机不确定而产生的问题(例如线程安全等)。


2. Linux并发编程:

使用应用级并发的应用程序称为 “并发程序”(concurrent program)。现代操作系统提供了三种基本的构造并发程序的方法:
进程、I/O多路复用、线程。

2.1 进程的优劣:

进程有 独立的地址空间 既是优点也是缺点。
一方面,一个进程不可能不小心覆盖另一个进程的虚拟内存,这是一个明显的优点;
另一方面,独立的地址空间使得进程共享状态信息变得更加困难:为了共享信息,它们必须使用显式的IPC机制(进程间通信机制)。
基于进程的设计的另一个缺点是,它们往往比较慢,因为 进程控制和IPC的开销很高

2.2 I/O多路复用技术的优劣:

  1. 优点:

控制: 事件驱动设计的一个优点是,它比基于进程的设计给了程序员更多的对程序行为的控制;

共享数据: 另一个优点是,一个基于I/O多路复用的事件驱动服务器是运行在单一进程上下文中的,因此每个逻辑流都能访问进程的全部地址空间,这使得在流之间 共享数据 变得很容易

便于调试: 一个与作为 单进程 运行的相关优点是,你可以利用熟悉的调试工具,例如GDB,来调试你的并发服务器,就像对顺序程序那样;

高效: 最后,事件驱动程序往往比基于进程的设计要高效的多,因为它们不需要进程上下文切换来调度新的流。

  1. 缺点:

编码复杂:一个实例的echo服务器程序使用事件驱动方式的代码量要比基于进程的服务器多三倍,并且随着并发粒度的减小,复杂性还会上升。这里的“粒度”是指每个逻辑流每个时间片执行的指令数量。

2.3 多线程程序中的共享变量:

2.3.1 线程内存模型:

一组并发线程运行在一个进程的上下文中。
每个线程都有自己独立的 线程上下文,包括:线程ID、栈、栈指针(%rsp)、程序计数器(PC指针)、条件码、通用目的寄存器值(GPRS)。
每个线程和其他线程一起共享进程上下文的剩余部分(代码段(存储机器指令)、常量区、读写段(也叫静态区,存储全局不变量)、堆、共享库代码、数据区域)。线程也共享打开的文件描述符(fd)。

从实际操作的角度来说,让一个线程去读或写另一个线程的寄存器值是不可能的;另一方面,任何线程都可以访问共享虚拟地址内存的任意位置,如果某个线程修改了内存位置,那么其他每个线程最终都能在它读这个位置时发现这个变化。
因此,寄存器 是从不共享的,而 虚拟内存 总是共享的

各自独立的线程栈的内存模型不是那么整齐清楚的。这些栈保存在虚拟地址空间的 区域中,并且通常是被相应的线程独立的访问。我们说通常而不是总是,是因为 不同的线程栈是不对其他线程设防的,所以 如果一个线程以某种方式得到一个指向其他线程的栈的指针,那么它就可以读写这个栈的任何部分

2.4 线程安全:

线程安全性(thread safety) 是用来 描述一个函数的属性,我们说一个函数是否为线程安全的:

如果一个函数被多个并发线程反复的调用时,它会一直产生正确的结果,那么这个函数就是“线程安全的”(thread-safe);
相反,如果一个函数不是线程安全的,我们就说它是“线程不安全的”(thread-unsafe)。

线程不安全的函数可分为以下四类:

  1. 第一类:不保护共享变量的函数:

例如在函数内操作全局变量或者互斥区时不加互斥锁,这样的函数就是线程不安全的。
一个典型的例子是两个线程同时一个全局变量进行++操作:

线程的执行可细分为下面的过程:
(1)加载全局变量val到线程独享的寄存器中(例如%rdi);
(2)更新寄存器%rdi的值;
(3)将寄存器%rdi的更新值存回到全局变量val中。

当两个对等线程在一个单处理器上并发运行时,机器指令以某种顺序一个接一个的完成,可能是并发执行执行定义了两个线程中的指令的某种全序(或者交叉),这就可能会造成两个线程的值覆盖。

#include <stdio.h>
#include <pthread.h>

void *thread(void *arg) {
    int *val = (int*)arg;
    for(int i = 0; i < 10000; i++) {
        (*val)++;
    }
    return NULL;
}

int main() {
    pthread_t tid1, tid2;
    int a = 0;

    pthread_create(&tid1, NULL, thread, (void*)&a);
    pthread_create(&tid2, NULL, thread, (void*)&a);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    printf("a = %d\n", a);
    return 0;
}

------

//每次运行的结果都不一样:
val = 14506
OR
val = 16047
OR
val = 15772
...

这种情况的解决方法也很简单,只需要对共享变量 或者 互斥区 在访问前进行加锁,在访问后释放锁即可:

//改进后的程序:
#include <stdio.h>
#include <pthread.h>

pthread_mutex_t m;

void *thread(void *arg) {
    int *val = (int*)arg;
    for(int i = 0; i < 10000; i++) {
        pthread_mutex_lock(&m);
        (*val)++;
        pthread_mutex_unlock(&m);
    }
    return NULL;
}

int main() {
    pthread_t tid1, tid2;
    pthread_mutex_init(&m, NULL);
    int a = 0;

    pthread_create(&tid1, NULL, thread, (void*)&a);
    pthread_create(&tid2, NULL, thread, (void*)&a);
    pthread_join(tid1, NULL);
    pthread_join(tid2, NULL);

    printf("a = %d\n", a);
    pthread_mutex_destroy(&m);
    return 0;
}

------

val = 20000
  1. 第二类:保持跨越多个调用的状态的函数:

例如 rand() 函数就是线程不安全的,因为当前调用的结果依赖于前次调用的中间结果。

使得像rand这样的函数线程安全的唯一方式是重写它,使得它不再使用任何static数据(全局变量 或 静态局部变量),而是依靠调用者在参数中传递状态信息。这样做的缺点是修改起来非常麻烦且容易出错。

  1. 第三类:返回指向静态变量的指针的函数:

例如 ctime 和 gethostbyname,ctime() 函数的返回值是char*指针,指向一个静态变量,这样的函数在多线程编程中也是危险的,因为正在被一个线程使用的结果会被另一个线程悄悄的覆盖。

//ctime()函数原型:
#include <time.h>
char* ctime(const time_t *clock);

------
//使用举例:
time_t timep;
char *result = ctime(&timep);
pritnf("%s\n", result);

------
//输出结果:
Thu Oct 30 17:02:32     4461614

有两种方法来处理这类线程不安全函数:

(1)一种选择是 重写函数,要求调用者必须传递存放返回结果的缓存地址。例如 ctime_r就是这样做的:

#include <time.h>
char* ctime_r(const time_t* clock, char* buf);
//*buf由调用者事先申请好内存,调用函数时传入,用于存储结果

这种处理方式要求程序员能够修改函数的源代码。
但当函数的源代码难以修改或者不能修改(例如源代码过于复杂 或者 无源代码可用),则需要使用另一种方式。

(2)另一种选择是使用 加锁复制(lock-and-copy) 技术:

基本思想是将线程不安全函数与互斥锁联系起来,在每一个调用位置,对互斥锁加锁,调用线程不安全函数,将函数返回的结果复制到一个私有的内存位置,然后对互斥锁解锁。

为了尽可能的较少对调用者的修改,应该定义一个线程安全的包装函数,例如:

//ctime的线程安全的包装函数:
char *ctime_ms(const time_t* timep, char* privatep) {
    char* sharedp;
    P(&mutex);
    sharedp = ctime(timep);
    strcpy(privatep, sharedp);
    V(&mutex);
    return privatep;
}
  1. 第四类:调用线程不安全函数的函数。

2.4.1 可重入性:

有一类重要的线程安全函数,叫做 “可重入函数”(reentrant function),其特点是 当它们在被多个线程调用时,不会引用任何共享数据。(可重入函数是安全函数的子集)

在这里插入图片描述

可重入函数通常要比不可重入的线程安全的函数要高效一些,因为它们不需要同步操作。

如果所有的函数函数参数都是值传递的(没有指针传递、没有引用传递),并且所有的数据引用都是本地的自动栈变量(没有引用静态或全局变量),那么函数就是 “显式可重入的”(explicitly reentrant)

如果允许显式可重入的函数中的一些参数是引用传递的(即允许传递指针),则此时这个函数就是 “隐式可重入的”(implicitly reentrant),如果调用线程小心的传递 指向非共享数据的指针,那么它是可重入的。

一个函数要成为可重入的,必须具有如下几个特点:
(1)不使用任何(局部)静态或全局的非const变量;
(2)不返回任何(局部)静态或全局的非const变量的指针;
(3)仅依赖于调用方提供的参数;
(4)不依赖任何单个资源的锁(mutex等);
(5)不调用任何不可重入的函数。

可重入是并发安全的强力保障,一个可重入的函数可以在多线程环境下放心使用。


3. 扩展:程序的机器级表示:

3.1 寄存器:

  1. PC寄存器:

程序计数器(通常称为“PC”,在x86-64中用 %rip 表示),用于保存将要执行的下一条指令在内存中的地址

  1. 整数寄存器:(16位、32位系统中有8个,64位系统中有16个)

整数寄存器可以用来存储 地址(对应于C语言的指针) 或 整数数据。其中:有些被用来记录某些重要的程序运行状态(如 栈指针%rsp),而另外一些寄存器用来保存临时数据(例如过程的参数 和 局部变量,以及函数的返回地址)。

  1. 条件码寄存器:

条件码寄存器保存着最近执行的 算数或逻辑指令的状态信息。它们用来执行控制或条件数据流中的条件变化,例如用来实现if和while语句。

  1. 向量寄存器:

用于存放整数或浮点数值。

虽然C语言提供了一种模型,可以在内存中声明和分配各种数据类型的对象,但是 机器代码只是简单的将内存看成一个很大的、按字节寻址的数组。 C语言中的聚合数据类型,例如数组和结构体,在机器代码中用一组连续的字节表示。即使对标量数据,汇编代码也不区分有符号或无符号整数,不区分各种类型的指针,甚至于不区分指针和整数。

程序内存包括(一个进程的内存包括):
程序的可执行机器代码(代码部分)、操作系统需要的一些信息、用来管理过程调用和返回的运行时栈(线程级别)、用户分配的内存块(堆内存)。

一个x86-64 的中央处理单元(CPU)包含一组16个存储64位值的 “通用寄存器”(GPRS,General Purpose Registers)。这些寄存器用来存储整数数据和指针。

(在最初的8086中有 8个16位 的寄存器,即图中的 %ax 到 %sp;扩展到 IA32 架构时,这些寄存器也扩展成 8个32位 寄存器,标号从 %eax 到 %esp; 扩展到 x86-64 时,原来的8个寄存器扩展成64位,标号从 %rax 到 %rsp,除此之外,还增加了 8个新的寄存器,从 %r8 到 %r15 (共计 16个64位 寄存器))

整数寄存器:

在这里插入图片描述

其中最特别的是 栈指针 %rsp,用来指明运行时栈的结束位置。

3.2 操作数指示符:

大多数指令有一个或多个 “操作数”(operand),用于指示出一个操作中要使用的源数据值,以及放置结果的位置

操作数可以分为三种类型:
(1)立即数: (immediate)用于表示常数值;
写法是 ‘$’ 符号后面跟一个用标准C表示法表示的整数;

(2)寄存器: (register)用于表示某个寄存器的内容
写法是用 ra 表示任意寄存器,用 R[ra] 表示寄存器的值。

(3)内存引用: 它会根据计算出来的地址访问某个内存位置。
用符号 Mb[Addr] 来表示对存储在内存中从地址 Addr 开始的 b个字节值的引用。

3.3 MOV:数据传送指令:

MOV类指令用于把数据从源位置复制到目的位置,不做任何变化。

MOV类指令由四条指令组成,它们执行同样的操作,区别在于操作的数据大小不同,分别是 1、2、4、8字节:

movb	:	传送字节(1字节)
movw	:	传送字(2字节)
movl	:	传送双字(4字节)
movq	:	传送四字(8字节)

例如:

movl %0x4050, %eax			//Immediate --> Register, 4 bytes
movw %bp, %sp				//Register --> Register, 2 bytes
movb (%rdi, %rcx), %al		//Memory --> Register, 1 byte
movb $17, (%rsp)
movq %rax, -12(%rbp)		//Register --> Memory, 8 bytes

数据传送示例:


long exchange(long *xp, long y) {
    long x = *xp;
    *xp = y;
    return x;
}

------

//对应的汇编语言:
long exchange(long *xp, long y)
xp in %rdi, y in %rsi

exchange:
	movq	(%rdi), %rax
	movq	%rsi, (%rdi)
	ret

%rdi : 存储函数的第1个参数
%rax : 存储函数的返回值
%rsi : 存储函数的第2个参数

3.4 入栈、出栈:

数据的入栈、出栈(将数据压入到程序栈中、从程序栈中弹出数据)也算是数据的传送操作。

栈在处理 过程调用(也就是函数调用) 中起到至关重要的作用,栈是一种遵循“后进先出”的数据结构,使用 push 操作将数据压入栈中,使用 pop 操作弹出栈。

数据插入、删除的一端称为 栈顶,另一端称为 栈底

栈向下增长,所以栈顶元素的地址是所有栈中元素地址中最低的。
栈指针 %rsp 保存着栈顶元素的地址。

push和pop指令都只有一个操作数:压入的数据源 和 弹出的数据目的方。

将一个四字(8 byte,64 bit)值压入栈中,首先要讲栈指针(%rsp)减8,然后将值写到新的栈顶地址,因此,下面的指令等价:

pushq	%rbp

------

sub		$8, %rsp
movq	%rbp, (%rsp)	//先对%rsp指针减8,再将%rbp中存储的值赋值给%rsp

对于 pop指令 也是一样的过程:

pop		%rax			//%rax用于保存函数的返回值

------

movq	(%rsp), %rax	//Read %rax from stack
addq	$8, %rsp		//Increment stack pointer

3.5 过程:

过程 是软件中一种很重要的抽象,它提供了一种封装代码的方式用一组指定的参数和一个可选的返回值实现了某种功能。 然后,可以在程序中不同的地方调用这个函数。设计良好的软件用过程作为抽象机制,隐藏某个行为的具体实现,同时又提供清晰简洁的接口定义,说明要计算的是哪些值,过程会会程序状态产生什么样的影响。
不同编程语言中,过程的形式多样:函数(function)、方法(method)、子例程(subroutine)、处理函数(handler)等。

以下面一个过程为例:
过程P调用过程Q,Q执行后返回P

要实现这个动作,需要实现下面几个机制:

  1. 传递控制:

在进入程序Q时,PC指针(程序计数器)必须指向Q代码的起始地址,然后在从Q返回时,要把PC指针(程序计数器)设置为P中调用Q后面那条指令的地址;

  1. 传递数据:

指的是参数和返回值的传递。
P必须能够向Q传递参数,Q必须能够向P传递返回值。

  1. 分配和释放内存:

指的是局部变量的分配和释放。
在刚开始进入到Q时,Q可能需要为局部变量分配空间,而在返回前,又必须释放这些存储空间。

3.6 运行时栈:

C语言使用栈数据结构提供的后进先出的内存管理原则来实现过程调用机制。

一个通用的栈帧结构如图所示:
在这里插入图片描述

当x86-64过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间。(所以说 寄存器空间和 栈空间 是完全独立开的。

当x86-64过程需要的存储空间超出寄存器能够存放的大小时,就会在栈上分配空间,这个部分称为过程的 “栈帧”(stack frame)

为了提高空间和时间效率,x86-64过程只分配自己需要的栈帧部分。 如果一个被调用过程的参数不超过 6个,那么所有的参数都可以通过寄存器传递。 实际上,许多函数设置根本不需要栈帧。当所有的局部变量都可以保存在寄存器中,而且该函数不会调用任何其他函数时,就可以这样处理。

3.7 进程的虚拟地址空间:

进程的总体目标是希望每个进程从逻辑上看都可以独占计算机的资源。

操作系统的多任务功能使得CPU能够在多个进程之间很好的共享,从进程的角度看好像是它独占了CPU而不用考虑与其他进程分享CPU的事情。

操作系统的I/O抽象模型也很好的实现了I/O设备的共享和抽象。

在早期的计算机中,程序是直接运行在物理内存上的,也就是说程序运行时所访问的地址都是物理地址。这样做的弊端是当多个进程同时运行时:
(1)地址空间不隔离;(进程的地址空间内的数据可能被其他进程恶意改写)
(2)内存使用效率低;
(3)程序运行的地址不确定。(每次程序需要装入运行时,都需要给它重新从内存中分配一块足够大的内存)

3.7.1 分段:(Segmention)

对于内存的抽象,最开始人们使用的是一种叫做 “分段”(Segmention) 的方法,基本思路是把一段与程序所需要的内存空间大小的虚拟空间映射到某个地址空间。

在这里插入图片描述

分段的方法解决上面直接使用物理地址中的问题一和问题三。

分段的方法做到了 地址隔离,因为程序A和程序B被映射到了两块不同的物理空间区域,它们之间没有任何重叠。如果程序A访问虚拟空间的地址超出了 0x00A00000 这个范围,那么硬件就会判断这是一个非法的访问,拒绝这个地址请求。

同时,对于程序来说它们被分配到的物理地址对于进程来说是透明的,程序不再需要重定位。

但是分段的这种方法没有解决第二个问题,即内存使用效率的问题。

3.7.2 分页:(Paging)

分页的基本方法是把地址空间人为的分成固定大小的页,每一页的大小由硬件决定,或硬件支持多种大小的页,由操作系统选择决定页的大小。

例如Intel Pentium系列处理器支持 4KB 或 4MB 的页大小,操作系统可以选择4KB也可以选择4MB的页大小。

目前几乎所有的PC上的操作系统都使用 4KB 大小的页,对于32位虚拟地址空间(4GB),按4KB分页时,总共有 1048576个页。

在这里插入图片描述

虚拟空间的页称为 “虚拟页”(VP,Virtual Page)
物理内存中的页称为 “物理页”(PP,Physical Page)
磁盘中的页称为 “磁盘页”(DP,Disk Page)


参考内容:
《深入理解计算机系统》第3章、第12章
《程序员的自我修养》第1章

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值