文章目录
- 线程的概念
- Linux下的进程
- Linux下的线程
- 进程再理解
- Linux线程和接口的认识
- 代码验证
- 二级页表
- 页表
- 线程的优点
- 线程的缺点
- 线程异常
- 线程的用途
- 进程和线程的关系
- 线程控制
- 线程
- 线程ID和LWP
- 线程等待
- 线程终止
- 线程分离
- 线程ID及进程地址空间布局
线程的概念
我们知道,进程在各自独立的地址空间中运行,进程之间共享数据需要用mmap
或者进程间通信机制,本节我们学习如何在一个进程的地址空间中执行多个线程。有些情况需要在一个进程中同时执行多个控制流程,这时候线程就派上了用场,比如实现一个图形界面的下载软件,一方面需要和用户交互,等待和处理用户的鼠标键盘事件,另一方面又需要同时下载多个文件,等待和处理从多个网络主机发来的数据,这些任务都需要一个“等待-处理”的循环,可以用多线程实现,一个线程专门负责与用户交互,另外几个线程每个线程负责和一个网络主机通信。
以前我们讲过,main
函数和信号处理函数是同一个进程地址空间中的多个控制流程,多线程也是如此,但是比信号处理函数更加灵活,信号处理函数的控制流程只是在信号递达时产生,在处理完信号之后就结束,而多线程的控制流程可以长期并存,操作系统会在各线程之间调度和切换,就像在多个进程之间调度和切换一样。
Linux下的进程
进程:
PCB
+mm_struct
+页表
+MMU
+物理内存
。先将代码加载到虚拟内存,然后通过预加载对代码进行一点点地映射到物理内存。
Linux下的线程
在Linux
当中其实没有线程这一概念,我们把轻量级进程叫做线程,线程是运行在进程当中的执行流,一个进程最少要有一个执行流,线程也是CPU
调度的最小单位。
如下为线程的简略示意图:
在Linux当中,没有创建线程专属的结构体,而是对进程PCB
进行稍作修改,也就是轻量级进程。
各线程共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(
SIG_IGN
、SIG_DFL
或者自定义的信号处理函数)- 当前工作目录
- 用户id和组id
但如下资源是每个线程各有一份的:
- 线程id
- 上下文,包括各种寄存器的值、程序计数器和栈指针
- 栈空间
errno
变量- 信号屏蔽字
- 调度优先级
进程再理解
曾经说进程就是一个task_struct
+进程地址空间+页表+MMU
+物理内存,这么说也没错,因为进程至少有一个执行流,当执行流为1的时候就成立。
但现在又知道,一个进程当中会存在多个执行流,因此,如下一整块才被称之为进程。
进程是分配系统资源的实体。
线程是CPU调度的最小单位。
Linux线程和接口的认识
在Linux中线程是用进程模拟实现的 所以说Linux中不会给我们提供线程的操作接口 (这里解释下 其实Linux不是没有能力去提供这些操作接口 而是它想要保持一个相对自由的状态给用户) 而是给我们提供了一个在同一个进程地址空间中创建PCB的方法 分配给资源指定的PCB
但是作为一个用户来说 使用这种方法的学习成本太高了 我们更需要一个完整的线程库
所以说一些应用级的开发工程师就在应用层对于轻量级的Linux接口进行封装成为了我们经常使用的原生线程库
代码验证
#include <pthread.h>
int pthread_create(pthread_t *thread,
const pthread_attr_t *restrict attr,
void *(*start_routine)(void*), void *restrict arg);
Compile and link with -pthread.
以下是各个参数的具体含义:
thread
:指向新创建的线程标识符的指针。线程创建后,其标识符将被存储在此指针所指向的位置。attr
:指向线程属性的对象。若不关心线程的属性,可以将其设为NULL。start_routine
:指向新线程的入口点函数的指针。此函数必须是静态链接的,且不能返回任何值。arg
:传递给新线程入口点函数的参数。这个参数可以为空。注意:
pthread_create
函数返回一个整型值,表示线程的创建状态。如果线程创建成功,它将返回0;如果出现错误,它将返回一个非零值。其它线程可以调用
pthread_join
得到start_routine
的返回值,类似于父进程调用wait(2)
得到子进程的退出状态。
pthread_create
成功返回后,新创建的线程的id被填写到thread
参数所指向的内存单元。我们知道进程id的类型是pid_t
,每个进程的id在整个系统中是唯一的,调用getpid()
可以获得当前进程的id,是一个正整数值。线程id的类型是thread_t
,它只在当前进程中保证是唯一的,在不同的系统中thread_t
这个类型有不同的实现,它可能是一个整数值,也可能是一个结构体,也可能是一个地址,所以不能简单地当成整数用printf
打印,调用pthread_self()
可以获得当前线程的id。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
int num = 0;
using namespace std;
void *test(void *args)
{
while (1)
{
cout << "线程ID:" << pthread_self() << " "<< "num:" << num++ << endl;
sleep(1);
}
}
int main(void)
{
pthread_t tid1, tid2;
pthread_create(&tid1, NULL, test, (void *)"hello");
pthread_create(&tid2, NULL, test, (void *)"hello");
sleep(1);
cout << "tid1:" << tid1 << endl;
cout << "tid2:" << tid2 << endl;
while (1)
{
sleep(1);
}
return 0;
}
可以看到,这里定义的全局变量num
并没有像多线程一样发生修改的时候就会重新映射页表,使得进程之间的资源独立,而是多个线程之间共享了这个num
。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
//int num = 0;
using namespace std;
void *test(void *args)
{
int num=0;
while (1)
{
cout << "线程ID:" << pthread_self() << " "<< "num:" << num++ << endl;
sleep(1);
}
}
int main(void)
{
pthread_t tid1, tid2;
pthread_create(&tid1, NULL, test, (void *)"hello");
pthread_create(&tid2, NULL, test, (void *)"hello");
sleep(1);
cout << "tid1:" << tid1 << endl;
cout << "tid2:" << tid2 << endl;
while (1)
{
sleep(1);
}
return 0;
}
但如果定义在函数体内部,可以发现num
就不共享了,这是由于线程都有自己的栈空间,线程也有自己的私有数据。
我们可以通过ps -aL
命令来查看线程。
PID代表的是Process ID(进程ID),LWP代表的是Light Weight Process(轻量级进程)。这两个概念并不相同,但在一些场景下有所关联。
- PID:PID是指系统中唯一的标识一个进程的一个数字,通常用来标识某个具体的进程。当创建了一个新的进程时,系统会给它分配一个唯一的PID。
- LWP:LWP是指轻量级进程,它与普通进程相比具有较低的开销。通常来说,一个普通的进程会有若干个LWP组成,每个LWP都负责执行某一部分代码或任务。
在
ps -aL
命令中,PID
表示的是整个进程的标识,而LWP
表示的是进程中的某个具体的执行实体。例如,如果一个进程有多个LWP
,那么在ps -aL
命令中,每个LWP
都会有一个自己的PID
和LWP ID
,以此来区分不同LWP
之间的执行状态。
这里的PID:20486
的PID
和LWP
怎么是一样的呢?
因为这个是主线程,另外两个就是我们创建的子线程。
二级页表
在讲二级页表之前先说一下一级页表。
假如我们没有页表,所有的内存都是段式访问,而段式内存访问有一个缺点:
我们的进程C需要11M的内存,但是由于空闲区域F1和F2都不能满足进程C需要的内存,同时由于进程A和B都是活跃进程,因此不可以被腾出,于是进程C必须等待进程A或进程B腾出相应的内存空间,但是这种等待是不可控的。
F1和F2两块区域的和是足够进程C的内存,但由于我们为进程C分配的内存是连续的,因此这种段式内存的利用率是低下的。
为了解决这种分段模式下进程的线性地址等同于物理地址问题,我们必须要将线性地址和物理地址解绑,解绑以后线性地址连续,但物理地址可以不连续。
这就需要借助分页机制。
页表
(MIT6.S081)页表
线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多
- 与进程之间的切换相比 线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速IO操作结束的同时 程序可执行其他的计算任务
- 计算密集型应用 为了能在多处理器系统上运行 将计算分解到多个线程中实现
- IO密集型应用 为了提高性能 将IO操作重叠 线程可以同时等待不同的IO操作
- 计算密集型:执行流的大部分任务,主要以计算为主。比如加密解密、大数据查找等。
- IO密集型:执行流的大部分任务,主要以IO为主。比如刷磁盘、访问数据库、访问网络等。
线程的缺点
- 性能损失: 一个很少被外部事件阻塞的计算密集型线程往往无法与其他线程共享同一个处理器 如果计算密集型线程的数量比可用的处理器多 那么可能会有较大的性能损失 这里的性能损失指的是增加了额外的同步和调度开销 而可用的资源不变
假设有一个单核处理器上的程序,其中有三个计算密集型任务A、B和C。如果这三个任务同时运行,则它们都需要等待处理器空闲出来才能继续运行。如果任务A正在运行,则任务B和C都需要等待任务A完成才能继续运行,反之亦然。这就是所谓的同步和调度开销。 为了减少这种情况带来的开销,可以将任务A、B和C分别放在不同的处理器上运行。这样,它们就可以并行地执行,从而减少了同步和调度开销。但是,如果只有一个处理器,则这种方法无效。 因此,为了获得最佳的性能,应根据实际情况确定最适合的线程数量。如果任务是计算密集型的,则线程数等于可用的处理器数量;如果是I/O密集型的任务,则线程数应小于可用的处理器数量。健壮性降低: 编写多线程需要更全面更深入的考虑 在一个多线程程序里 因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的 换句话说 线程之间是缺乏保护的。
- 缺乏访问控制: 进程是访问控制的基本粒度 在一个线程中调用某些OS函数会对整个进程造成影响
- 编程难度提高: 编写与调试一个多线程程序比单线程程序困难得多
线程异常
- 如果某一个线程出现除零错误、野指针等问题,整个进程都会崩溃
- 线程是进程的执行分支,线程出现了异常,就类似进程出现异常,进而触发信号,整个进程都会出错。
线程的用途
- 合理的使用多线程 能提高CPU密集型程序的执行效率
- 合理的使用多线程 能提高IO密集型程序的用户体验(如生活中我们一边写代码一边下载开发工具 就是多线程运行的一种表现)
进程和线程的关系
线程控制
线程
#include <pthread.h>
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
- 返回值:成功返回0,失败返回错误号,以前学过的系统函数都是成功返回0,失败返回-1,错误号会保存在全局变量
errno
当中,而pthread
库的函数都是通过返回值返回错误号,虽然每个线程都有一个errno
,但这是为了兼容其它函数接口提供的,pthread
库本身并不使用它,通过返回值返回错误码更加清晰。 - thread:是一个输出型参数,创建成功的线程
ID
会写入这个变量当中。 - attr:用于设置线程的特殊属性,
NULL
表示使用默认的线程属性。 - start_routine:创建线程后要执行的函数。
- arg:函数的参数。
在一个线程中调用了
pthread_create()
创建新的线程后,当前线程从pthread_create()
返回继续往下执行,而新的线程所执行的代码由我们传递给pthread_create()
的函数指针start_routine
决定,start_routine
会接受一个参数,就是arg
,参数类型为void*
,这个指针具体是什么类型由调用者自己决定,void*
可以接受任何类型的指针,通过强制类型转换可以转化为自己想要的类型。start_routine
的返回类型也是void*
,这个指针的含义同样是由调用者自己定义。当start_routine
函数返回的时候,这个线程就退出了。其他线程可以调用pthread_join()
来获取start_routine
的返回值,就类似于父进程可以调用wait()
得到子进程的退出状态。
下面我们让主线程调用pthread_create函数创建一个新线程,此后新线程就会跑去执行自己的新例程,而主线程则继续执行后续代码。
#include <iostream>
#include <pthread.h>
#include <unistd.h>
#include<string.h>
using namespace std;
void *test(void *args)
{
int num=0;
while (1)
{
cout << "线程ID:" << pthread_self() << " "<< "num:" << num++ << endl;
sleep(1);
}
}
int main(void)
{
pthread_t tid1;
int err=pthread_create(&tid1, NULL, test, (void *)"hello");
if(err!=0)
{
fprintf(stderr, "can't create thread: %s\n", strerror(err));
exit(1);
}
sleep(1);
while (1)
{
cout<<"我是主线程,线程ID:"<<pthread_self()<<endl;
sleep(2);
}
return 0;
}
运行代码后可以看到,新线程每隔一秒执行一次打印操作,而主线程每隔两秒执行一次打印操作。
while :; do ps axj | head -1 ; ps axj |grep pthread | grep -v grep ; echo "------------------------------------------------------------
---"; sleep 1 ; done; echo "-------------------------------------------------
通过如上命令可以查看进程,可以看到始终只有一个进程
ps -aL
通过如上命令可以查看线程,可以看到有两个线程,一个主线程,一个子线程。
线程ID和LWP
pthread_self()
拿到的是用户级的线程ID,而LWP
是内核级的线程ID,类似于文件描述符和inode
之间的关系。
线程等待
#include <pthread.h>
int pthread_join(pthread_t thread, void **value_ptr);
- thread:被等待的线程ID(用户级)。
- value_ptr:线程退出时的退出码信息。
- 如果
thread
线程通过return
返回,value_ptr
所指向的单元里存放的是thread
线程函数的返回值。 - 如果
thread
线程被别的线程调用pthread_cancel
异常终止掉,value_ptr
所指向的单元里存放的是常数PTHREAD_CANCELED
。 - 如果
thread
线程是自己调用pthread_exit
终止的,value_ptr
所指向的单元存放的是传给pthread_exit
的参数。
如果对thread
线程的终止状态不感兴趣,可以传NULL
给value_ptr
参数。
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include<string>
#include<string.h>
#include <sys/types.h>
using namespace std;
struct info
{
string name;
int return_val;
};
void *thr_fn1(void *arg)
{
printf("thread 1 returning\n");
return (void*)1;
}
void *thr_fn2(void *arg)
{
printf("thread 2 exiting\n");
pthread_exit((void *)2);
}
void *thr_fn3(void *arg)
{
while(1) {
printf("thread 3 writing\n");
sleep(1);
}
}
int main(void)
{
pthread_t tid;
void* tret;
pthread_create(&tid, NULL, thr_fn1, NULL);
pthread_join(tid, &tret);
printf("thread 1 exit code %d\n", (int*)tret);
pthread_create(&tid, NULL, thr_fn2, NULL);
pthread_join(tid, &tret);
printf("thread 2 exit code %d\n", (int*)tret);
pthread_create(&tid, NULL, thr_fn3, NULL);
sleep(3);
pthread_cancel(tid);
pthread_join(tid, &tret);
printf("thread 3 exit code %d\n", (int*)tret);
return 0;
}
可以看到,通过返回值返回的线程其tret
都是返回的值,而是由pthread_cancel()
取消线程的,返回值都是PTHREAD_CANCELED
的整形。
在Linux的pthread库中常数PTHREAD_CANCELED
的值是-1。可以在头文件pthread.h
中找到它的定义:
#define PTHREAD_CANCELED ((void *) -1)
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
#include<string>
#include<string.h>
#include <sys/types.h>
using namespace std;
struct info
{
string name;
int return_val;
};
void *thr_fn1(void *arg)
{
printf("thread 1 returning\n");
return (void*)1;
}
void *thr_fn2(void *arg)
{
printf("thread 2 exiting\n");
exit(-1);
}
void *thr_fn3(void *arg)
{
while(1) {
printf("thread 3 writing\n");
sleep(1);
}
}
int main(void)
{
pthread_t tid;
void* tret;
pthread_create(&tid, NULL, thr_fn1, NULL);
pthread_join(tid, &tret);
printf("thread 1 exit code %d\n", (int*)tret);
pthread_create(&tid, NULL, thr_fn2, NULL);
pthread_join(tid, &tret);
printf("thread 2 exit code %d\n", (int*)tret);
pthread_create(&tid, NULL, thr_fn3, NULL);
sleep(3);
pthread_cancel(tid);
pthread_join(tid, &tret);
printf("thread 3 exit code %d\n", (int*)tret);
return 0;
}
将代码稍作修改,如果我先终止线程2,那么整个进程都会退出,线程3也不会被执行了。
线程终止
如果需要只终止某个线程而不终止整个进程,可以有三种方法:
- 从线程函数
return
。这种方法对主线程不适用,从main
函数return
相当于调用exit
。 - 一个线程可以调用
pthread_cancel
终止同一进程中的另一个线程。 - 线程可以调用
pthread_exit
终止自己。
#include <pthread.h>
void pthread_exit(void *value_ptr);
value_ptr
是void *
类型,和线程函数返回值的用法一样,其它线程可以调用pthread_join
获得这个指针。需要注意,
pthread_exit
或者return
返回的指针所指向的内存单元必须是全局的或者是用malloc
分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
线程分离
一般情况下,线程终止之后,其终止状态会一直保持到其他线程调用pthread_join
来获取线程的终止状态。但如果线程被detach
(分离)了,一旦线程之中之后直接会被操作系统回收掉。
不能对一个已经处于detach状态的线程调用pthread_join
,这样的调用将返回EINVAL
。对一个尚未detach的线程调用pthread_join
或pthread_detach
都可以把该线程置为detach状态,也就是说,不能对同一线程调用两次pthread_join
,或者如果已经对一个线程调用了pthread_detach
就不能再调用pthread_join
了。
#include <pthread.h>
int pthread_detach(pthread_t thread);
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
#include <unistd.h>
void *thr_fn1(void *arg)
{
pthread_detach(pthread_self());
printf("thread 1 returning\n");
sleep(3);
return (void *)1;
}
int main(void)
{
pthread_t tid;
void* tret;
pthread_create(&tid, NULL, thr_fn1, NULL);
pthread_join(tid, &tret);
printf("thread 1 exit code %d\n", (int*)tret);
while(1)
{
}
return 0;
}
线程ID及进程地址空间布局
线程之间到底共享了哪些资源?
线程之间到底哪些资源是私有的?
我们知道,一个程序会经历预处理、编译、汇编、链接这四个过程,而链接就是在链接动静态库。
也就是说线程会共享动态库中的所有代码。
一个进程的虚拟地址空间一般可以大致划分为代码区(text)、只读数据区(rodata)、初始化数据区(data)、为初始化数据区(bss)、堆(heap)、共享内存区(.so,mmap的地方)、栈(stack)、内核区(kernel)。
对于 Linux 进程或者说主线程,其 stack 是在 fork 的时候生成的,实际上就是复制了父亲的 stack 空间地址,然后写时拷贝 (cow) 以及动态增长。
然而对于主线程生成的子线程而言,其 stack 将不再是这样的了,而是事先固定下来的。
线程栈不能动态增长,一旦用尽就没了,这是和生成进程的 fork 不同的地方。
线程(非主线程)的栈的大小是固定的,其会在空闲的堆(堆顶附近自顶向下分配)或者是空闲栈(栈底附近自底向上分配),因此线程栈局部函数中分配的变量是存放到各自分配的栈空间,因此可以说是线程私有的,又因为该线程栈的边界是设定好的,线程栈之间有以小块guardsize
用来隔离保护各自的栈空间,一旦另一个线程踏入到这个隔离区,就会引发段错误,因此该线程栈的大小的固定的。
主线程从进程栈分配空间,大小并不是固定的,如果分配空间大于进程栈空间,那么直接运行时出现段错误。
通过ulimit -a
可以看到,栈的大小是8192kb
,也就是8M
。
从 heap 的顶部向下分配。
ps -ajax| grep pthread 查看 pid
cat /proc/[pid]/maps 这个显示进程映射了的内存区域和访问权限。
可以看到:在 heap 下面连续的几个属性为 rw-p 的地址大小刚好都为 8192kb。并且每个都在边界穿插了一个大小为 1000H(4096kb) 的边界空间。
从 stack 底部向上分配
ulimit -s unlimited 设置 stack size 为 unlimited,注意虽然设置了stack size为无限,但是实际上其并不是无限的,而也是固定大小的线程栈,大小为1mb。
然后 cat /proc/[pid]/maps 查看虚拟地址空间的映射。
可以看到,这种情况下线程栈是分配在 stack 底附近,自底向上生长的。
当每增加一个线程,就会在栈或者堆区创建一个结构体,这个结构体来源于动态库。
对于主线程的代码或者变量,所有线程都是共享的。
void thread(void* var) {
int* p = (int*)var;
*p = 2;
}
int main() {
int a = 1;
pthread_t tid;
pthread_create(&tid, NULL, thread, (void*)&a);
return 0;
}
而对于线程的私有栈,数据是不共享的。