这篇文章主要介绍线程的概念和线程控制
线程概念
当一个进程加载到内存中,操作系统会为创建进程相关的数据结构(PCB,进程地址空间,页表等)来管理进程。
关于进程,之前的博客中有详细的介绍。Linux–>进程概念
因为进程具有独立性,每创建一个进程都会创建其对应的PCB,进程地址空间,页表等数据结构,如果创建一个进程时只创建进程PCB,不在创建进程地址空间,页表等,通过一定的手段,将当前进程的资源划分给不同的PCB。以这种方式就叫做创建线程。Linux下特有的线程实现方案。创建出来后的结构如下:
将每一个PCB看作一个线程,此时这个进程就有四个线程。线程是CPU调度的基本单位,也就是说,CPU不关心你是进程还是线程,只关心PCB。以每个PCB做为执行流。
下面红色框线圈出来的是进程的内核数据结构。可以看出,线程是在进程内部执行的,在进程的地址空间内执行。
之前所说的进程= 内核数据结构+可执行程序。是以普通用户的角度来看待的,如果内核的角度来看的话,进程就是承担分配资源的基本实体。因为进程向内存申请资源,申请完后由线程来执行。
有了这些了解以后,有些概念就很好理解了。
- 单执行流(进程只有一个PCB)
- 多执行流(进程中有多个PCB)
如果以CPU的视角来看待的话,CPU不关心当前执行的是进程还是线程,只关心进程PCB。CPU是以进程PCB为单位来调度的。
如果是多线程的话,CPU就是同时并发的去调度进程PCB。这样效率更高。(当然,一个进程创建的线程太多的话,之后带来副作用。一般CPU是几核的最多就创建几个线程)
这就是Liunx下线程实现的原理。不同的操作系统有不同实现方案,比如window下的线程有专门的数据结构来维护。而Linux则没有专门为线程设计数据结构,以进程PCB模拟实现线程的。所以Linux下没有提供线程相关的系统调用接口,而是在用户层实现了一套多线的方案,以库的形式提供给开发人员使用,这个库叫pthread线程库。(可以当作系统调用接口看待)
总结关于线程的概念:
- 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序 列”
- 一个进程至少都有一个执行线程
- 线程在进程内部运行,本质是在进程地址空间内运行
- 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化 透过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程 执行流
线程资源
上面说过,线程执行在进程地址空间上,那么进程地址空间上的资源如何分配呢?
线程和进程共享数据,但是线程有自己的私有数据:
- 线程id
- 一组寄存器(保存上下文信息)
- 栈(每个线程都有临时的数据,每个线程都要有自己的私有栈区,避免CPU调度时覆盖临时数据而影响到整个进程)
- 错误码
- 信号屏蔽字
- 调度优先级
进程中的多个线程共享同一个地址空间,代码区,数据段,堆区都是共享的,如果定义一个函数,在各线程 中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:
- 文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
大部分进程资源都是线程公有的,但是寄存器和栈区必须独立。要保存线程的上下文信息(即线程执行到哪了,下一步执行什么)和保存线程的临时数据寄存器和栈区就必须要私有。
而进程地址空间中的栈区只有一个,多线程如何分配?
进程地址空间中的栈区,一般分配给主线程。其余线程的栈区,由原生线程库pthread提供。
pthread是Linux下的一个原生线程库,供用户使用,存放在磁盘中,当进程使用这个库时,就会把pthread库加载到内存中,然后通过页表映射到进程地址空间中的共享区供线程访问。库中不仅仅提供了线程的方法,还提供了线程的栈结构。最终被映射到了地址空间中的共享区。在共享区划分出一个个区域做为每个线程的栈区
通过这样的方式保证了每个线程私有一个栈区。
线程的优点
- 创建一个新线程的代价要比创建一个新进程小得多(只有创建PCB,而进程除了PCB还要创建进程地址空间,页表)
- 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多
- 线程占用的资源要比进程少很多
- 能充分利用多处理器的可并行数量
- 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务
- 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现
- I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。
线程的缺点
- 创建太多的线程会导致性能损失,一般共识是几核CPU最多创建几个线程
- 健壮性降低:原因就是大部分资源共享,互相之间没有独立性,一个线程一旦异常,整个进程就会退出
- 缺乏访问控制:原因还是资源共享,多进程之间会发生写时拷贝,而线程不会
- debug难:主要还是因为资源共享,同一份数据被不同的线程共享,无法快速准确的定位bug
多线程有利有弊,合理的使用多线程能提高CPU密集程序的执行效率,比如百度网盘边播边下功能,就用到了多线程,一个线程播放,再启动一个线程进行下载。
进程和线程的关系
线程控制
有关线程的操作都需要用到pthread库。使用这个库用g++编译时要加-lpthread选项
创建线程(pthread_create)
使用到pthread_create
函数。使用man手册认识一下pthread_create
函数
- 参数一:thread
- 输出型参数:获取新创建的线程id
- 参数二:attr
- 用于设置创建线程的属性,传入NULL表示使用默认属性即可
- 参数三:start_routine
- 该参数是一个函数地址,表示线程例程,即线程启动后要执行的函数。该函数的返回值和参数都是void*。
- 参数四:arg
- 传给回调函数的参数
- 返回值
- 创建成功返回0,失败返回错误码。
使用示例:
让主进程创建一个新线程
void* TouchThread(void* arg)
{
cout << "创建一个新线程" << (char*)arg << endl;
return nullptr;
}
int main()
{
//线程id
pthread_t tid = 0;
//创建线程
pthread_create(&tid,nullptr,TouchThread,(void*)"thread 1");
cout << tid << endl;
return 0;
}
运行结果:
新创建线程中的输出语句并没有打印,这里的原因是:和多进程一样,谁先执行由调度器决定,用户无法干涉。这里主线程创建完新线程之后就直接return退出了。新线程调度器还没来得及调度,主线程就退出了。主线程退出整个程序都结束了,新线程的资源也都没有了。目前解决这个问题可以让主线程sleep一会。然后在退出。还可以使用进程等待让主线程等待新进程执行完后回收新线程在退出。也可以让主线程死循环,不要退出。
下面以死循环不退出的方式为例:
主线程中创建一个线程之后,然后死循环执行任务,让新创建的线程也一直执行死循环。都向终端打印信息
void *TouchThread(void *arg)
{
while(1)
{
cout << "newthread" << endl;
sleep(1);
}
return nullptr;
}
int main()
{
// 线程id
pthread_t tid = 0;
// 创建线程
pthread_create(&tid, nullptr, TouchThread, (void *)"thread 1");
while(1)
{
cout << "mian thread" << endl;
sleep(2);
}
return 0;
}
运行结果:
可以使用ps -aL命令查看当前系统的轻量级进程的信息(简单理解一下就是线程信息吧)
可以看出,当前进程有两个线程,他们的PID一样,属于一个进程,但是LWP不一样,LWP是轻量级进程的id。不是线程id。
注意:在Linux中,应用层的线程是和LWP是一一对应的,操作系统调度的时候,是按照LWP来进行调度的,并非PID。
只不过在没学习多线程之前,都是单线程的进程,PID和LWP是一样的。
这个例子可以很好的证明线程是执行在进程内部的。
线程的id(pthread_t类型)是一个地址,创建线程的第一个例子中打印过线程的id。
为什么线程的id是一个地址?
原因就是上面说过的,线程的栈区是由库提供的,库加载到内存中,通过页表映射到地址空间的共享区。而线程库除了提供了线程的栈区的属性,还提供了其他的属性,比如线程局部存储等,这些属性在一个结构体里面(struct_ptherad)。映射到共享区后,一个个结构体就pthread_t的属性,而这个结构体的起始地址就是线程的id。
更准确的来说,线程id是一个地址这个地址是进程地址空间上共享区的一个地址。
线程等待 (pthread_join)
使用pthread_join
等待线程
函数原型:
int pthread_join(pthread_t thread, void **retval);
- 参数一:thread
- 指定等待线程的id
- 参数二:retval
- 线程退出时的退出码信息。(在回调函数中设置退出码,不关心设置为NULL即可)
示例:
让上面例子主线程等待新创建线程。
void *TouchThread(void *arg)
{
cout << "创建一个新线程" << (char *)arg << endl;
return nullptr;
}
int main()
{
// 线程id
pthread_t tid = 0;
// 创建线程
pthread_create(&tid, nullptr, TouchThread, (void *)"thread 1");
cout << tid << endl;
//等待新线程
pthread_join(tid,nullptr);
return 0;
}
运行结果:
创建的新线程成功执行。
如果关心线程退出码的话,可以在回调函数中设置返回值。比如下面这个例子:
主线程创建一个新线程,向终端输出5次hello world后退出,退出码设置为2024。
主线程等待新线程执行结束,获取到新线程的退出码,并打印。
void *TouchThread(void *arg)
{
int a = 5;
while (a--)
{
cout << "Hello World" << endl;
}
return (void*)2024;
}
int main()
{
// 线程id
pthread_t tid = 0;
// 创建线程
pthread_create(&tid, nullptr, TouchThread, (void *)"thread 1");
// 等待新线程
void* retval;//接收线程退出码
pthread_join(tid, &retval);
cout << "mian thread join success retval = " << (long long)retval << endl;
return 0;
}
注意:
这里我是64位机器,指针的大小是8字节,在使用retval时要强转为大小一样的类型。
运行结果:等待成功并且拿到线程的退出码
线程等待的必要性
- 如果不等待,已经退出的线程资源没有回收,仍然在地址空间内,也会造成内存泄漏。
- 如果不等待回收线程的话,再新创建线程,新创建的线程不会复用之前线程的地址空间。
一般是有主线程等待的。且等待的时候只能阻塞的等待(等待期间不能执行其他任务)。如果要非阻塞等待的话,使用线程分离即可。在后面会介绍线程分离。
线程异常
任意一个线程只要出现异常,会导致整个进行整体退出。比如下面这个例子:
主线程创建出一个新线程,在新线程中进行/0操作(这个操作会让线程出异常),观察整个进程的状态
void* TouchThread(void* arg)
{
int a = 10;
while(a--)
{
if(a == 5)
{
a /= 0;
}
cout << (char*)arg << endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,TouchThread,(void*)"new thread");
pthread_join(tid,nullptr);
cout << "main thread" << endl;
return 0;
}
上面程序会输出五次new thread,然后执行/0操作。
运行结果:
可以发现,一旦线程出异常后,影响到的是整个进程,整个进程也就退出了。
这也是多线程的一个缺点,健壮性太差,一旦多线程场景下,一个线程出异常整个进程就退出。
线程终止
线程终止可以通过回调函数return终 止,上面的例子都是通过return终止的。
线程库中也提供了终止线程的函数。pthread_eixt()
;
函数原型void pthread_exit(void *retval);
- 参数retval
- 一个输出型参数,获取线程的退出码
例子:
主线程创建一个线程,新线程向终端打印五次hello world退出
- 一个输出型参数,获取线程的退出码
void* TouchThread(void*)
{
int a = 5;
while(a--)
{
cout << "Hello world" << endl;
sleep(1);
}
//终止线程
pthread_exit((void*)2024);
}
int main()
{
pthread_t tid;
//创建线程
pthread_create(&tid,nullptr,TouchThread,nullptr);
//等待线程
void* retval;
pthread_join(tid,&retval);
cout << "new thread exit code = " << (long long) retval << endl;
return 0;
}
运行结果:
新创建的线程执行5次后,使用pthread_eixt()
终止了线程。主线程等待并且成功获取到退出码。
注意:pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其他线程得到这个返回指针时,线程函数已经退出了。
线程库中还提供了一个pthread_cancle
函数。通过这个函数,可以在一个线程中终止指定id的线程。
比如下面这个例子:
主线程中创建一个新线程,让新线程每隔一秒向终端打印信息,执行五秒后,在主线程中终止掉新创建的线程。
void *TouchThread(void *)
{
while (1)
{
cout << "hhh" << endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, TouchThread, nullptr);
sleep(5);
//取消线程
pthread_cancel(tid);
//等待线程
void* retval;
pthread_join(tid, &retval);
cout << (long long)retval << endl;
return 0;
}
运行结果:
可以发现pthread_cancel终止一个线程后使用pthread_join等待后线程的退出码为-1。
这是因为,使用pthread_cancel,线程会在return之前退出,在pthread_cancel函数中设置了退出码为-1。
也可以使用pthread_cancel取消自己,使用pthread_self()
函数获取自己线程的id。
//cancel自己
void *TouchThread(void *)
{
int a = 5;
while(a--)
{
cout << "hello world" << endl;
}
//终止线程
pthread_cancel(pthread_self());
return (void*)100;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, TouchThread, nullptr);
//等待线程
void* retval;
pthread_join(tid, &retval);
cout << (long long)retval << endl;
return 0;
}
运行结果:
cancel自己后,后面的return还是会执行的。
线程分离
- 使用
pthread_join
的时候只能阻塞等待,导致主线程无法继续执行其他任务。 - 创建一个线程之后,如果不关心其返回值,join是一种负担。使用线程分离就可以做到当线程退出时,自动释放资源,不再需要主线程join了。
函数原型:
int pthread_detach(pthread_t thread);
参数是线程id。返回值:分离成功返回0,失败返回错误码。
比如下面这个例子:
创建一个线程后,自己分离自己。新创建的线程打印执行五秒后退出,退出时会自动调用detach释放资源。主线程不用在阻塞等待。主线程执行自己的任务。
void* TouchThread(void* arg)
{
pthread_detach(pthread_self());
int a = 5;
while(a--)
{
cout << "Hello World" << endl;
sleep(1);
}
return nullptr;
}
int main()
{
pthread_t tid;
pthread_create(&tid,nullptr,TouchThread,(void*)"new thread");
sleep(1);
int a = 7;
while(a--)
{
cout << "main thread" << endl;
sleep(1);
}
return 0;
}
运行结果:使用脚本监控系统线程信息。while :; do ps -aL | head -1 && ps -aL | grep thread_test; sleep 1; done
可以看出新创建的线程执行结束后自动回收资源。
简单来讲,线程分离就是当线程被设置为分离状态后,线程结束时,它的资源会被系统自动的回收,而不再需要在其它线程中对其进行 pthread_join() 操作。