《程序员的自我修养》- 线程


#线程在哪里

线程可以理解为进程的一部分。比如我现在编写了一个C语言程序,其中用到pthread.h中的函数实现了多线程的功能。然后我将这个程序编译为可执行文件,当我运行它时,我相当于创建了一个进程,这个进程拥有自己的虚拟地址空间,而进程的线程就存在于虚拟地址空间的用户空间中,各个线程共享进程的内存空间。

#定义,组成与作用

  • 定义:线程是程序执行流的最小单元。
  • 组成:线程由线程ID(TID),当前指令指针(PC),寄存器集合和堆栈组成。
  • 作用:可以简单抽象为非阻塞,并发或并行,性能,共享。

下面是对上面几项的解释与理解:

  • 定义可以和组成结合起来理解,这三点构成了一个最小程序执行流,称为一个线程。
    1. 一个程序执行流需要一个独立的存储空间来保证正常运行,即一部分寄存器和堆栈(线程理论上拥有对进程用户空间的所有数据,包括其他线程的数据,但其实线程也拥有完全私有的存储空间)。
    2. 程序执行流所谓“流”即一个流程,需要知到当前执行到的位置,即程序计数器(PC)。在32位Intel处理器中,该计数器的功能由寄存器EIP来承担。
    3. 线程ID正如其名,是每一个线程唯一的标识,它在资源分配等方面起着重要的作用。
  • 线程的作用:
    1. 非阻塞是指当某一个线程遇到需要等待或者极计算量较大,耗时较长的问题时,其他线程可以继续运行下去,而不是必须等待该线程完成工作。如带有用户界面的大规模计算程序需要快速对用户的操作作出反应,不需要等待负责计算的线程。
    2. 并发与并行主要影响体现在网络服务器上,线程使得同时处理多个用户的请求成为可能。
    3. 对于多核CPU来说,程序可以通过按一定方式配置多线程来实现对性能的最大化利用,从而提高程序的运行效率,这也是服务器用的核心数几十甚至上百的CPU的游戏表现并不突出的原因:游戏一般不会适配过多的CPU核心数,从而造成了极大的资源浪费。
    4. 多线程之间可以以较高的效率实现数据的共享。

并发与并行并不相同:并发是指CPU同一时间只执行一项任务,只是任务之间切换的速度极快,以至于造成多个任务同时进行的假象。而并行则是货真价实的多任务同时进行,但它依赖于CPU硬件方面的支持(核心数)

#线程的调度与优先级

线程在调度中拥有三种状态,分别是:

  • 运行:线程正在执行
  • 就绪:此时线程可以立即执行,但是CPU被占用
  • 等待:此时线程正在等待某一事件发生,无法执行

这三种状态之间的相互转换可以用下面这张图来解释: 解释一下图中出现的部分名词的意思:

  • 时间片:处于运行中的线程所拥有的一段可以执行的时间。
  • 等待:运行中的线程停止执行,在时间片用尽前脱离运行状态。
  • IO密集型线程:由于线程进入等待状态一般是为了等待IO时间,如用户输入,因此频繁等待的线程被称为IO密集型线程。
  • CPU密集型线程:一般需要大量计算的线程等待次数较少,故称为CPU密集型线程。
  • 优先级调度:操作系统根据线程的不同优先级来进行调度的方式,拥有更高优先级的线程会更早执行。其实现代的操作系统调度方式可能更多。更复杂,此处仅仅是一个较为典型的例子。

在线程的调度中,还有一种少见的线程,它们即使是在时间片用尽的情况写也依然保持运行,除非该线程进入等待状态或者主动放弃执行,这种线程被称为不可抢占式线程。这样做的其中一个目的是保证线程所处理的数据的一致性,即防止其他线程抢占后修改数据,

#Linux下的多线程

不同于Windows,早期Linux下的线程概念并不明确。在Linux内核中,进程和线程都称为任务,每一个任务都类似于一个单一的进程。但Linux中的任务可以共享内存空间,因此共享内存空间的多个任务可以从概念上认为是线程。如可以进行资源共享操作的系统调用clone便可以创建出概念上的进程。 下面是一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
#define _GNU_SOURCE  // activate clone() function
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <sched.h>
int incre(void *arg);
int main(int argc, char *argv[])
{
size_t STACK_SIZE = 1024; // stack size of the thread
void *stack = malloc(STACK_SIZE); // allocate stack for thread
int i = 0;
unsigned long flags = 0;
if (argc > 1 && !strcmp(argv[1], "vm")) {
flags = CLONE_VM;
} // activate memory sharing when vm in argument
if (clone(incre, stack + STACK_SIZE, flags, &i) == -1) {
perror("Clone Error");
exit(1);
}
for (int a = 0; a < 10; ++a) {
printf("Now the value of i is %d.\n", i);
}
return 0;
}
int incre(void *arg)
{
while (*(int *)arg < 10000) {
*(int *)arg += 1;
}
return 0;
}

需要注意当参数为vm时才开启内存共享的选项,这意味着clone所产生的是一个概念上的线程,但如果不开启该选项的话,产生的就是一个进程。 下面我们来看一下程序的执行结果:

可以看到,相比较于左边创建了新进程的程序,右边创建新线程的程序具有明显的数据共享的特征。而且从右边的输出结果中,我们也可以更清晰地看到操作系统对线程的调度(即轮换执行)的结果。 然而在多线程的程序中,如果某一个数据对于多个线程来说都至关重要,那么该如何去处理呢。这就是之后要提到的线程安全的内容了。 虽然clone函数创建出的只是概念上的进程,但其实在用户层面上已经出现了符合POSIX标准的线程库,可以用以下方式查看:

1
2
> $ getconf GNU_LIBPTHREAD_VERSION
NPTL 2.29

← Prev 《程序员的自我修养》- 线程安全 | 《程序员的自我修养》- 虚拟地址空间 Next →