Linux:线程概念 | 线程控制
一、线程概念
一个进程需要访的大部分资源,诸如自身的代码、数据、new\malloc的空间数据、命令行参数和环境变量、动态库、甚至是系统调用访问内核代码…都是通过虚拟地址空间来访问的。换而言之,进程地址空间是进程的资源窗口!!
进程创建费时费力。在创建时,我们需要为进程创建PCB、地址空间、页表、将进程自身的代码和数据换入内存并建立映射、将进程PCB状态改为R状态、添加带运行队列中… 但如果现在已经存在一个线程了,我仅仅将进程PCB复制多份,然后让所有“进程”PCB全部指向同一个虚拟地址空间。然后通过技术手段,将虚拟地址空间合理分配给每一个“进程”。当CPU调度执行该“”进程“时,只会执行原本进程中的一部分代码和数据,执行我们要执行任务的一部分任务,我们将这种比传统“进程”更加轻量化的进程就称为线程!!
线程是在进程内部执行,比进程更加轻量化的一种执行流!!

二、pthread原生线程库
在Linux中,如果操作系统真的支持线程,此时在计算机中必然存在大量的线程,所以OS需要对线程进行管理。先描述,在组织!!操作系统需要创建struct TCB结构体来描述线程,然后通过链表将所有线程进行链接,组织起来。 并且线程是进程内部的一种执行流,还要将进程和线程进行接藕。更重要的是,线程也需要被调度,也就意味着我们需要为线程调度重新设计一套调度算法。
但其实我们发现线程TCB和进程PCB所需要的属性字段是差不多的,都需要pid、状态、调度优先级…并且两者都是需要被调度被运行的!如果Linux既要支持线程,也需要满足对线程使用的需求。其实是可以用PCB充当TCB,这样我们就可以把系统中所有线程调度和切换的代码在线程层面上全部复用起来。并且对线程的管理工作,也可以直接复用进程管理代码!我们将这种直接创建PCB,然后让所有PCB指向同一个地址空间,在对资源进行分配的线程实现方式称为Linux中线程的实现方案!!我们也将这种线程称为轻量级进程!!
在Linux中没有真正意义上的线程,而是用轻量级进程来充当线程。但这是Linux OS自身的独特设计,对于用户来说只认识进程和线程。所以我们在Linux操作系统和用户之间封装了一层软件层。该软件层向下调用轻量级进程相关接口,向上给用户提供线程的控制接口,而该软件层被称为pthread原生线程库!这也是为啥我们编写线程代码时,在编译阶段需要主动链接pthread库原因!
可以通过ps -aL查看系统线程!

三、线程 VS 进程
- 线程进程内部中一种执行流,线程比进程更加轻量化。(创建、调度切换、删除更加轻量化)
- 线程时CPU调度的基本单位;而进程是资源分配的基本单位。
- 进程拥有独立的地址空间和页表;而线程是共享进程的地址空间和其他资源!
- 进程的栈是由进程地址空间维护;而线程的栈空间是由pthread维护,映射到共享区中的!
3.1 线程切换轻量化原理
CPU在调度执行某行代码时,由于局部性原理,计算机会将该行周围的代码全部加载到CPU cache缓存中。而cache缓存是基于进程,进程切换,chche数据立即失效,需要重新对数据进行热加载,将数据加载到cache缓存中;但对于线程切换来说,下一个线程极有可能还是访问这些代码和数据,大概率是不需要进程切换掉cache缓存中的数据!!(局部性原理也给磁盘加载到内存的预加载提供了理论基础)
除此之外,在CPU中还存在很多寄存器来存储进程/线程的上下文,存在一些寄存器保存的内容执行地址空间和页表。对于线程来说,地址空间和页表是相同的,这也意味着线程切换时我们不需要将所有线程的寄存器全部切换走;只需将少部分保存临时数据的寄存器切换即可!
- 需要切换的寄存器少!
- 不需要重新跟新cache!

3.2 线程私有数据
线程和进程共享大部分数据,诸如代码段、数据段、全局数据等都是共享的。除此外,还包含如下进程资源:
- 共享当前进程文件描述符表
- 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
- 当前工作目录
- 用户id和组id
除此在外,线程中还存在一些数据是线程私有的,具体如下:
- 线程拥有独立的寄存器硬件上下文!线程是独立被调度的,线程必然拥有独立的上下文,比如运行过程中产生的临时数据。
- 线程都有独立栈结构!‘一般地址空间中的栈属于主线程,然后通过在堆上申请空间,从当其他新线程的栈空间!
- 线程ID、信号屏蔽字、调度优先级、errno等
3.3 线程优缺点
1)、线程优点
- 线程的创建、删除、切换比进程更加简单轻量;线程占用的系统资源比进程更少!
- 对于计算密集型应用,线程可以利用多处理器并行特点,将计算分解到多个线程并行处理,提高效率!
- 对于I/O密集型应用,可以将I/O操作进行重叠,以线程同时等待不同的I/O操作,提高性能!
2)、线程缺点
- 如果线程的数量比处理器多,增加了额外的同步和调度开销,而可用的资源不变,导致性能损失!
- 线程会导致整体程序的健壮性降低。如果存在一些错误导致线程异常,会导致进程整体异常退出!
- 线程缺乏访问控制!进程是访问控制的基本粒度。对于线程,OS中并没有提供相关的方法进行线程访问控制!
- 编写难度高!线程出现问题,通常是很难进行排查的。
四、线程相关函数及功能
4.2 线程创建
【函数接口】:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
void *(*start_routine) (void *), void *arg);
Compile and link with -pthread//编译时,需要链接pthread原生线程库
返回值:成功返回0;失败返回错误码
thread:输出型参数,返回线程id。attr:设置线程属性,为NULL表示使用默认属性。start_routine:新线程执行方法的函数地址。arg:传给start_routine函数的参数。- 错误检查:传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回。pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值判定,因为读取返回值要比读取线程内的errno变量的开销更小
【实例】:
void* ThreadRoution(void* arg)
{
std::cout << "I am new thread" << std::endl;
const char* threadname = (const char*)arg;
std::cout << threadname << std::endl;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, ThreadRoution, (void*)"thread-1");
std::cout << "I am main thread" << std::endl;
sleep(1);
return 0;
}

4.3 获取自身线程id
pthread_t pthread_self(void);
4.4 线程取消
int pthread_cancel(pthread_t thread);
返回值:成功返回0;失败返回错误码
4.5 线程终止
线程退出有3种方式:return返回、pthread_cancel取消线程、pthread_exit()线程终止。(不能调用exit()函数,该函数用于进程退出,一般调用,所以相关线程将全部退出!)
【进程终止函数原型】:
void pthread_exit(void *retval);
retval:retval不要指向一个局部变量。- 返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)。
- pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。
4.6 线程等待
一般情况下,主线程需要对新线程进行等待,否则会导致类似于进程的“僵尸”问题,导致内存泄漏!
int pthread_join(pthread_t thread, void **retval);
thread:需要等待的线程id
retval:指向待等待线程的返回值
调用该函数的线程会被阻塞,知道id为thread的线程终止。依据线程终止时的不同方式,retval中会返回不同的结果!
- 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
- . 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED(值为-1,原型为
#define PTHREAD_CANCELED ((void *) -1))。 - 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
- 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。
4.7 线程分离
默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。如果不关心线程的返回值,join是一种负担,这个时候,我们可以将线程设置为分离状态,告诉系统,当线程退出时,自动释放线程资源!
线程组内其他线程对目标线程进行分离,也可以是线程自己分离!但joinable和分离是冲突的,一个线程不能既是joinable又是分离的!
int pthread_detach(pthread_t thread);
- 线程分离后是可以cancel的,但不能join!!
五、线程id含义
4.1 线程id含义
下面我们来看看一段代码,看看线程id是啥?
void* ThreadRoution(void* arg)
{
std::cout << "I am new thread" << std::endl;
const char* threadname = (const char*)arg;
std::cout << threadname << std::endl;
}
int main()
{
pthread_t tid;
pthread_create(&tid, nullptr, ThreadRoution, (void*)"thread-1");
std::cout << tid << std::endl;
std::cout << "I am main thread" << std::endl;
return 0;
}
【运行结果】:

我们发现线程id非常大,很奇怪。那这究竟是啥?
在Linux中,系统提供了相关创建LWP的相关接口:clone。此时pthread库通过调用该函数来创建线程给上层用户使用!
#include <sched.h>
int clone(int (*fn)(void *), void *child_stack,
int flags, void *arg, ...
/* pid_t *ptid, struct user_desc *tls, pid_t *ctid */ );
//stack: 允许用户传入空间从当新轻量级进程的栈空间
// flag: 该函数也是fork()函数的底层原理,该标记位指明到创建的LWP是一个新的进程还是线程
既然已经可以创建出线程了,我们需要对线程进程管理。但一般资源都是由OS来管理,但操作系统中只存在轻量级进程,线程概念是由原生线程库pthread提供的。而该库会被加载到共享去中,该库可以看见所有用户创建的线程,所以需要pthread原生线程库自身对线程进程管理。先描述,在组织!

&emsp但;pthread原生线程库本质是第三方库,被加载映射到共享区中。具体如下:

所以在Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。
- 线程局部存储:我们在程序中定义一个全局变量,而该全局变量是所有线程共享的。此时我们可以加上
__thread编译选项。如__thread int gal_val = 100;。此时所有线程gal_val各自私有一份。
六、线程互斥
6.1 线程互斥相关概念
- 临界资源:在多线程中,多执行流访问的公共资源被称为临界资源。
- 临界区:在线程内部,访问临界资源的代码称为临界区。
- 互斥:在任何时候,只允许一个执行流进入临界区访问临界资源,对临界资源进行保护!
- 原子性:不会被任何调度机制打断的操作,原子性操作只有两态,要么完成,要么不完成!
6.2 多执行流访问数据不一致问题(抢票)
下面我们对线程进行简单疯转,然后在主逻辑代码中直接创建5个线程,并用vector管理起来。然后启动所有线程,每个线程执行抢票函数,最后在对所有线程进行等待回收!
抢票逻辑:首先定义一个全局变量tickets表示票的总数。抢票函数是一个死循环,只要还存在票(tickets >0),我们直接将票数减减,表示抢到票了。具体如下:
【线程封装】:
#include <iostream>
#include <pthread.h>
#include <functional>
#include <string>
template <class T>
using func_t = std::function<void(T)>;
template <class T>
class Thread
{
public:
Thread(const std::string &name, func_t<T> func, T data)
: _tid(0), _threadname(name), _isrunning(false), _func(func), _data(data)
{
}
static void *Routine(void *args)
{
Thread *ts = static_cast<Thread *>(args);
ts->_func(ts->_data);
return nullptr;
}
bool Start()
{
int n = pthread_create(&_tid, nullptr, Routine, this);
if (n != 0)
{
return false;
}
_isrunning = true;
return true;
}
bool Join()
{
if (!_isrunning)
return true;
int n = pthread_join(_tid, nullptr);
if (n == 0)
{
_isrunning = false;
return true;
}
return false;
}
std::string GetThreadName()
{
return _threadname;
}
bool IsRunning()
{
return _isrunning;
}
private:
pthread_t _tid;
std::string _threadname;
bool _isrunning;
func_t<T> _func;
T _data;
};
【抢票主逻辑】:
#include "Thread.hpp"
#include <unistd.h>
#include <vector>
int ticket = 10000; // 全局变量从当票数
void GetTicket(const std::string &threadname)
{
while (true)
{
if (ticket > 0)
{
usleep(1000); // 充当抢票逻辑
printf("%s get ticket: %d\n", threadname.c_str(), ticket); // 不要用cout,复现不了
ticket--;
}
else
{
break;
}
// 还存在后续其他动作,购买者信息填充
}
}
int main()
{
int num = 5;
std::vector<Thread<std::string>> threads;
for (int i = 1; i <= num; i++)
{
char name[128];
snprintf(name, sizeof(name), "%s-%d", "Thread", i);
Thread<std::string> td(name, GetTicket, name);
threads.push_back(td);
}
for (int i = 0; i < threads.size(); i++)
{
threads[i].Start();
}
for (int i = 0; i < threads.size(); i++)
{
threads[i].Join();
}
return 0;
}
【结果展示】:

我们惊奇发现最后票数竟然时负数,显然不合理。而这种现象就是我们没有对全局资源进行保护,导致的数据不一致问题!!
6.3 抢票数据不一致原因
首先票数ticket > 0和ticket--不是原子性的,由3条汇编组成!下面时ticket--汇编代码,ticket > 0类似!

在我们抢票之前需要先进行判断,票数是否大于0。但该逻辑分3步:先将ticket的值读到CPU中、CPU进行逻辑计算、将结果写回内存中!
但在该过程中,如果此时ticket的值为1。计算机刚将ticket的值读到CPU中,此时线程的事件片到来,需要切换到下一个线程。但不巧的是,之后的几个线程的发送了类似的情况!!此时所有线程的硬件上下文中ticket的值都为。
在后面调度这些进程时,首先回恢复线程上下文,即ticket为1。所有线程全部进入临界区访问临界资源。导致ticket一直被减减,减到负数!!


6.4 互斥量
要解决上述问题,需要进行加锁。在Linux中,我们将这把锁称为互斥量!!
1)、锁的创建和销毁
锁的创建有两种方式:
- 使用
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;定义一把全局的锁就可!使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁!! - 创建局部锁,并且需要对锁进行初始化!在使用完后需要对互斥量进行销毁!!
#include <pthread.h>
int pthread_mutex_init(pthread_mutex_t *restrict mutex,
const pthread_mutexattr_t *restrict attr); // 初始化互斥量
int pthread_mutex_destroy(pthread_mutex_t *mutex); // 销毁互斥量
2)、加锁、解锁
引入互斥量之后,在当问临界资源时需要先进行加锁操作。将可以的并行访问操作转化为串行访问!!
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_trylock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号
- 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功。
- 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。
3)、互斥量实现原理
单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题。为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。

下面我们来解释上面这段程序:(mutex本质就是一个变量,这里我们直接将其简化为一个整型,值为1)。

此时公共资源锁变量的值已经为0,表示锁被其他线程所持有。一旦其他线程重复上述的指令时,%al和mutex变量的值进行交换后,%al寄存器中的值为0.表示当前线程没有得到锁资源,被挂起等待。
同理unlock解锁时,直接将1填充到mutex变量内容,此时就表示解锁。然后唤醒等待线程即可!!
七、线程同步
前面抢票逻辑看似没问题,但在某些系统(比如Centos)下,其中一些线程抢占锁的能力是非常强的,导致其他线程长时间无法获得锁资源,导致线程饥饿问题。为了解决线程饥饿问题,我们引入同步机制,让所有线程按照一定的顺序进行调度!!
八、可重入 VS 线程安全
8.1 可重入 VS 线程安全
- 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
- 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。
8.2 常见的线程安全/不安全的情况
不安全:
- 不保护共享变量的函数。
- 函数状态随着被调用,状态发生变化的函数。比如在函数内部定义一个静态的计数器,用来表示函数进入多少次。而这种数据虽然已经类似于全局的,但作用域在函数内部。每次调用,函数内部的状态数据都会发生改变,可能不安全。
- 返回指向静态变量指针的函数。
- 调用线程不安全函数的函数
安全:
- 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的类或者接口对于线程来说都是原子操作
- 多个线程之间的切换不会导致该接口的执行结果存在二义性
8.3 常见可重入/不可重入的情况
不可重入:
- 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的
- 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构
- 可重入函数体内使用了静态的数据结构
可重入:
- 不使用全局变量或静态变量
- 不使用用malloc或者new开辟出的空间
- 不调用不可重入函数
- 不返回静态或全局数据,所有数据都由函数的调用者提供
- 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据
8.4 可重入与线程安全区别
- 线程安全是指多个线程并发执行同一段代码时,不会出现不同的结果。
- 重入指同一函数被不同执行流调用,当前流程还没执行完,其他流程就进入了。
- 可重入的函数是线程安全的,线程安全不一定可重入。

5048

被折叠的 条评论
为什么被折叠?



