【Linux】多线程深度剖析(一)

1. 线程概念

1.1 什么是线程

  • 在一个程序里的一个执行路线就叫做线程(thread)。更准确的定义是:线程是“一个进程内部的控制序列”。
  • 一切进程至少都有一个执行线程。
  • 线程在进程内部运行,本质是在进程地址空间内运行。
  • 在Linux系统中,在CPU眼中,看到的PCB都要比传统的进程更加轻量化
  • 通过进程虚拟地址空间,可以看到进程的大部分资源,将进程资源合理分配给每个执行流,就形成了线程执行流。

在这里插入图片描述

1.2 线程的优点

  • 创建一个新线程的代价要比创建一个新进程小得多。
  • 与进程之间的切换相比,线程之间的切换需要操作系统做的工作要少很多。
  • 线程占用的资源要比进程少很多。
  • 能充分利用多处理器的可并行数量。
  • 在等待慢速I/O操作结束的同时,程序可执行其他的计算任务。
  • 计算密集型应用,为了能在多处理器系统上运行,将计算分解到多个线程中实现。
  • I/O密集型应用,为了提高性能,将I/O操作重叠。线程可以同时等待不同的I/O操作。

1.3 线程的缺点

  1. 性能损失
    一个很少被外部事件阻塞的计算密集型线程往往无法与共它线程共享同一个处理器。如果计算密集型线程的数量比可用的处理器多,那么可能会有较大的性能损失,这里的性能损失指的是增加了额外的同步和调度开销,而可用的资源不变。
  2. 健壮性降低
    编写多线程需要更全面更深入的考虑,在一个多线程程序里,因时间分配上的细微偏差或者因共享了不该共享的变量而造成不良影响的可能性是很大的,换句话说线程之间是缺乏保护的。
  3. 缺乏访问控制
    进程是访问控制的基本粒度,在一个线程中调用某些OS函数会对整个进程造成影响。
  4. 编程难度提高
    编写与调试一个多线程程序比单线程程序困难得多。

1.4 线程异常

  • 单个线程如果出现除零,野指针问题导致线程崩溃,进程也会随着崩溃。
  • 线程是进程的执行分支,线程出异常,就类似进程出异常,进而触发信号机制,终止进程,进程终止,该进程内的所有线程也就随即退出。

1.5 线程用途

  • 合理的使用多线程,能提高CPU密集型程序的执行效率。
  • 合理的使用多线程,能提高IO密集型程序的用户体验。

2. 进程VS线程

2.1 进程和线程

  1. 进程是资源分配的基本单位
  2. 线程是调度的基本单位
  3. 线程共享进程数据,但也拥有自己的一部分数据:
    • 线程ID
    • 一组寄存器
    • errno
    • 信号屏蔽字
    • 调度优先级

2.2 进程的多个线程共享

同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:

  • 文件描述符表
  • 每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)
  • 当前工作目录
  • 用户id和组id

进程和线程的关系如下图:
在这里插入图片描述

3. 线程控制

3.1 POSIX线程库

  • 与线程有关的函数构成了一个完整的系列,绝大多数函数的名字都是以“pthread_”打头的。
  • 要使用这些函数库,要通过引入头文<pthread.h>。
  • 链接这些线程函数库时要使用编译器命令的“-lpthread”选项。

3.2 创建线程

功能:创建一个新的线程
原型:
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);
参数:
    thread:返回线程ID
    attr:设置线程的属性,attr为NULL表示使用默认属性
    start_routine:是个函数地址,线程启动后要执行的函数
    arg:传给线程启动函数的参数
返回值:成功返回0;失败返回错误码

错误检查:

  • 传统的一些函数是,成功返回0,失败返回-1,并且对全局变量errno赋值以指示错误。
  • pthreads函数出错时不会设置全局变量errno(而大部分其他POSIX函数会这样做)。而是将错误代码通过返回值返回。
  • pthreads同样也提供了线程内的errno变量,以支持其它使用errno的代码。对于pthreads函数的错误,建议通过返回值业判定,因为读取返回值要比读取线程内的errno变量的开销更小。
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

void* start_thread(void* arg)
{
    while(1)
    {
        printf("84-linux\n");
        sleep(1);
    }
}

int main()
{
    pthread_t tid;
    int ret = pthread_create(&tid, NULL, start_thread, NULL); // 创建的工作线程
    if(ret < 0)
    {
        perror("pthread_create");
        return -1;
    }

    while(1)
    {
        printf("i am main thread\n");
        sleep(1);
    }
    
    return 0;
}

3.3 线程ID及进程地址空间布局

  • pthread_ create函数会产生一个线程ID,存放在第一个参数指向的地址中。该线程ID和前面说的线程ID不是一回事。
  • 前面讲的线程ID属于进程调度的范畴。因为线程是轻量级进程,是操作系统调度器的最小单位,所以需要一个数值来唯一表示该线程。
  • pthread_ create函数第一个参数指向一个虚拟内存单元,该内存单元的地址即为新创建线程的线程ID,属于NPTL线程库的范畴。线程库的后续操作,就是根据该线程ID来操作线程的。
  • 线程库NPTL提供了pthread_ self函数,可以获得线程自身的ID:
pthread_t pthread_self(void);

对于Linux目前实现的NPTL实现而言,pthread_t类型的线程ID,本质就是一个进程地址空间上的一个地址。
在这里插入图片描述

3.4 线程终止

如果需要只终止某个线程而不终止整个进程,可以有三种方法:

  1. 从线程函数return。这种方法对主线程不适用,从main函数return相当于调用exit。
  2. 线程可以调用pthread_ exit终止自己。
  3. 一个线程可以调用pthread_ cancel终止同一进程中的另一个线程。

pthread_exit函数:

功能:线程终止
原型:
void pthread_exit(void *value_ptr);
参数:
    value_ptr:value_ptr不要指向一个局部变量。
返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)

需要注意,pthread_exit或者return返回的指针所指向的内存单元必须是全局的或者是用malloc分配的,不能在线程函数的栈上分配,因为当其它线程得到这个返回指针时线程函数已经退出了。

pthread_cancel函数:

功能:取消一个执行中的线程
原型:
int pthread_cancel(pthread_t thread);
参数:
    thread:线程ID
返回值:成功返回0;失败返回错误码

3.5 线程等待

为什么需要线程等待?

  • 已经退出的线程,其空间没有被释放,仍然在进程的地址空间内。
  • 创建新的线程不会复用刚才退出线程的地址空间。
功能:等待线程结束
原型:
int pthread_join(pthread_t thread, void **value_ptr);
参数:
    thread:线程ID
    value_ptr:它指向一个指针,后者指向线程的返回值
返回值:成功返回0;失败返回错误码

调用该函数的线程将挂起等待,直到id为thread的线程终止。thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下:

  1. 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
  2. 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED。
  3. 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元存放的是传给pthread_exit的参数。
  4. 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数。

3.6 分离线程

  • 默认情况下,新创建的线程是joinable的,线程退出后,需要对其进行pthread_join操作,否则无法释放资源,从而造成系统泄漏。
  • 如果不关心线程的返回值,join是一种负担,这个时候,我们可以告诉系统,当线程退出时,自动释放线程资源。
int pthread_detach(pthread_t thread);

可以是线程组内其他线程对目标线程进行分离,也可以是线程自己分离:

pthread_detach(pthread_self());

joinable和分离是冲突的,一个线程不能既是joinable又是分离的。

3.7 简单案例

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

#define THREAD_COUNT 4

struct ThreadData
{
	int num_;
	ThreadData()
	{
		num_ = -1; // 结构体构造,初始化
	}
};

class Data
{
public:
	void SetData(int data)
	{
		data_ = data;
	}

	int GetData()
	{
		return data_;
	}

private:
	int data_;
};


void* TreadStart(void* arg)
{
    pthread_detach(pthread_self());

	//ThreadData* td = (ThreadData*)arg;
	Data* d = (Data*)arg;

    //while(1)
	{
		//printf("i am work thread:%d\n", td->num_);
		printf("i am work thread:%d\n", d->GetData());
		sleep(1);
	}

    sleep(3);

    pthread_cancel(pthread_self());
    while(1)
    {
        sleep(1);
    }

	// 释放堆上的内存, 应该在线程的内部去释放, 当线程不在用的时候
	delete d;
}

int main()
{
	pthread_t tid[THREAD_COUNT];

	for (int i = 0; i<THREAD_COUNT; i++)
	{
		//struct ThreadData* td = new ThreadData; // 给每一个线程创建属于自己的空间 
		//td->num_ = i;
		//int ret = pthread_create(&tid, NULL, TreadStart, (void*)td); // 传结构体指针

		Data* d = new Data;
		d->SetData(i);
		int ret = pthread_create(&tid[i], NULL, TreadStart, (void*)d); // 传类的实例化指针

		if (ret < 0)
		{
			perror("pthread_create");
			return -1;
		}
	}

    //for(int i = 0; i < THREAD_COUNT; i++)
    //{
    //    pthread_join(tid[i], NULL); // 等待线程结束,否则阻塞
    //}

    printf("线程退出\n");

    //pthread_exit(NULL);
    
	while (1)
	{
		printf("i am main thread\n");
		sleep(1);
	}

	return 0;
}

4. 线程互斥(线程安全)

4.1 进程线程间的互斥相关概念

  • 临界资源:多线程执行流共享的资源就叫做临界资源。
  • 临界区:每个线程内部,访问临界资源的代码,就叫做临界区。
  • 互斥:任何时刻,互斥保证有且只有一个执行流进入临界区,访问临界资源,通常对临界资源起保护作用。
  • 原子性:不会被任何调度机制打断的操作,该操作只有两态,要么完成,要么未完成。

4.2 互斥量(互斥锁)mutex

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
  • 多个线程并发的操作共享变量,会带来一些问题。
// 操作共享变量会有问题的售票系统代码
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
#define THREAD_COUNT 4

int g_tickets = 100;

struct ThreadData
{
	int num_;
	ThreadData()
	{
		num_ = -1;
	}
};

void* TreadStart(void* arg)
{
    pthread_detach(pthread_self());
	ThreadData* td = (ThreadData*)arg;
    while(1)
	{
        if(g_tickets > 0)
        {
            printf("i am %p-%d, i have ticket is %d\n", pthread_self(),td->num_, g_tickets);
            g_tickets--;
        }
        else
        {
            break;
        }
	}
	delete td;
}

int main()
{
	pthread_t tid[THREAD_COUNT];

	for (int i = 0; i<THREAD_COUNT; i++)
	{
		struct ThreadData* td = new ThreadData; // 给每一个线程创建属于自己的空间 
		td->num_ = i;
		int ret = pthread_create(&tid[i], NULL, TreadStart, (void*)td);

		if (ret < 0)
		{
			perror("pthread_create");
			return -1;
		}
	}

	while (1)
	{
		printf("i am main thread\n");
		sleep(1);
	}

	return 0;
}
一次执行结果:
thread 4 sells ticket:100
...
thread 4 sells ticket:1
thread 2 sells ticket:0
thread 1 sells ticket:-1
thread 3 sells ticket:-2

为什么可能无法获得争取结果?

  • if 语句判断条件为真以后,代码可以并发的切换到其他线程。
  • usleep 这个模拟漫长业务的过程,在这个漫长的业务过程中,可能有很多个线程会进入该代码段。
  • --ticket 操作本身就不是一个原子操作。

-- 操作并不是原子操作,而是对应三条汇编指令:

  • load :将共享变量ticket从内存加载到寄存器中。
  • update : 更新寄存器里面的值,执行-1操作。
  • store :将新值,从寄存器写回共享变量ticket的内存地址。

要解决以上问题,需要做到三点:

  • 代码必须要有互斥行为:当代码进入临界区执行时,不允许其他线程进入该临界区。
  • 如果多个线程同时要求执行临界区的代码,并且临界区没有线程在执行,那么只能允许一个线程进入该临界区。
  • 如果线程不在临界区中执行,那么该线程不能阻止其他线程进入临界区。

要做到这三点,本质上就是需要一把锁。Linux上提供的这把锁叫互斥量(互斥锁)
在这里插入图片描述

4.3 互斥量的接口

4.3.1 初始化互斥量

初始化互斥量有两种方法:
方法1,静态分配:

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER

方法2,动态分配:

int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t
*restrict attr);
参数:
    mutex:要初始化的互斥量
    attr:NULL

4.3.2 销毁互斥量

销毁互斥量需要注意:

  • 使用 PTHREAD_ MUTEX_ INITIALIZER 初始化的互斥量不需要销毁。
  • 不要销毁一个已经加锁的互斥量。
  • 已经销毁的互斥量,要确保后面不会有线程再尝试加锁。
int pthread_mutex_destroy(pthread_mutex_t *mutex);

4.3.3 互斥量加锁和解锁

int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失败返回错误号

调用 pthread_ lock 时,可能会遇到以下情况:

  • 互斥量处于未锁状态,该函数会将互斥量锁定,同时返回成功
  • 发起函数调用时,其他线程已经锁定互斥量,或者存在其他线程同时申请互斥量,但没有竞争到互斥量,那么pthread_ lock调用会陷入阻塞(执行流被挂起),等待互斥量解锁。

4.4 简单案例:改进上面的售票系统

// thread_safe_lock.cpp
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>

#define THREAD_COUNT 4

pthread_mutex_t tex;

int g_tickets = 100;

struct ThreadData
{
	int num_;
	ThreadData()
	{
		num_ = -1;
	}
};

void* TreadStart(void* arg)
{
    pthread_detach(pthread_self());
	ThreadData* td = (ThreadData*)arg;
    while(1)
	{
        pthread_mutex_lock(&tex);
        if(g_tickets > 0)
        {
            printf("i am %p-%d, i have ticket is %d\n", pthread_self(),td->num_, g_tickets);
            g_tickets--;
            pthread_mutex_unlock(&tex);
        }
        else
        {
            pthread_mutex_unlock(&tex);
            break;
        }
	}
	delete td;
}

int main()
{
    pthread_mutex_init(&tex, NULL);
	pthread_t tid[THREAD_COUNT];

	for (int i = 0; i<THREAD_COUNT; i++)
	{
		struct ThreadData* td = new ThreadData;
		td->num_ = i;
		int ret = pthread_create(&tid[i], NULL, TreadStart, (void*)td);

		if (ret < 0)
		{
			perror("pthread_create");
			return -1;
		}
	}

	while (1)
	{
		//printf("i am main thread\n");
		sleep(1);
	}

    pthread_mutex_destroy(&tex);
	return 0;
}

4.5 互斥量实现原理

  • 经过上面的例子可知,单纯的 i++ 或者 ++i 都不是原子的,有可能会有数据一致性问题。
  • 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性,即使是多处理器平台,访问内存的 总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。

4.6 可重入VS线程安全

4.6.1 概念

  • 线程安全:多个线程并发同一段代码时,不会出现不同的结果。常见对全局变量或者静态变量进行操作,并且没有锁保护的情况下,会出现该问题。
  • 重入:同一个函数被不同的执行流调用,当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入。一个函数在重入的情况下,运行结果不会出现任何不同或者任何问题,则该函数被称为可重入函数,否则,是不可重入函数。

4.6.2 常见的线程不安全的情况

  • 不保护共享变量的函数。
  • 函数状态随着被调用,状态发生变化的函数。
  • 返回指向静态变量指针的函数。
  • 调用线程不安全函数的函数。

4.6.3 常见的线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
  • 类或者接口对于线程来说都是原子操作。
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性。

4.6.4 常见不可重入的情况

  • 调用了malloc/free函数,因为malloc函数是用全局链表来管理堆的。
  • 调用了标准I/O库函数,标准I/O库的很多实现都以不可重入的方式使用全局数据结构可重入函数体内使用了静态的数据结构。

4.6.5 常见可重入的情况

  • 不使用全局变量或静态变量。
  • 不使用用malloc或者new开辟出的空间。
  • 不调用不可重入函数。
  • 不返回静态或全局数据,所有数据都有函数的调用者提供。
  • 使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据。

4.6.6 可重入与线程安全联系

  • 函数是可重入的,那就是线程安全的。
  • 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。
  • 如果一个函数中有全局变量,那么这个函数既不是线程安全也不是可重入的。

4.6.7 可重入与线程安全区别

  • 可重入函数是线程安全函数的一种。
  • 线程安全不一定是可重入的,而可重入函数则一定是线程安全的。
  • 如果将对临界资源的访问加上锁,则这个函数是线程安全的,但如果这个重入函数若锁还未释放则会产生死锁,因此是不可重入的。

5. 线程同步

5.1 条件变量

  • 当一个线程互斥地访问某个变量时,它可能发现在其它线程改变状态之前,它什么也做不了。
  • 例如一个线程访问队列时,发现队列为空,它只能等待,只到其它线程将一个节点添加到队列中。这种情况就需要用到条件变量。

5.2 同步概念与竞态条件

  • 同步:在保证数据安全的前提下,让线程能够按照某种特定的顺序访问临界资源,从而有效避免饥饿问题,叫做同步。
  • 竞态条件:因为时序问题,而导致程序异常,我们称之为竞态条件。在线程场景下,这种问题也不难理解。

5.3 条件变量函数

5.3.1 初始化

int pthread_cond_init(pthread_cond_t *restrict cond,const pthread_condattr_t *restrict
attr);
参数:
    cond:要初始化的条件变量
    attr:NULL

5.3.2 销毁

int pthread_cond_destroy(pthread_cond_t *cond);

5.3.3 等待条件满足

int pthread_cond_wait(pthread_cond_t *restrict cond,pthread_mutex_t *restrict mutex);
参数:
    cond:要在这个条件变量上等待
    mutex:互斥量

5.3.4 唤醒等待

int pthread_cond_broadcast(pthread_cond_t *cond);
int pthread_cond_signal(pthread_cond_t *cond);

5.4 为什么pthread_ cond_ wait 需要互斥量?

  • 条件等待是线程间同步的一种手段,如果只有一个线程,条件不满足,一直等下去都不会满足,所以必须要有一个线程通过某些操作,改变共享变量,使原先不满足的条件变得满足,并且友好的通知等待在条件变量上的线程。
  • 条件不会无缘无故的突然变得满足了,必然会牵扯到共享数据的变化。所以一定要用互斥锁来保护。没有互斥锁就无法安全的获取和修改共享数据。
    在这里插入图片描述
  • 由于解锁和等待不是原子操作。调用解锁之后,pthread_ cond_ wait之前,如果已经有其他线程获取到互斥量,摒弃条件满足,发送了信号,那么pthread_ cond_ wait将错过这个信号,可能会导致线程永远阻塞在这个 pthread_ cond_ wait 。所以解锁和等待必须是一个原子操作。
  • int pthread_cond_wait(pthread_cond_ t *cond,pthread_mutex_ t * mutex); 进入该函数后,会去看条件量等于0不?等于,就把互斥量变成1,直到cond_ wait返回,把条件量改成1,把互斥量恢复成原样。

5.5 条件变量使用规范

等待条件代码:

pthread_mutex_lock(&mutex);
while (条件为假)
    pthread_cond_wait(cond, mutex);
修改条件
pthread_mutex_unlock(&mutex);

给条件发送信号代码:

pthread_mutex_lock(&mutex);
设置条件为真
pthread_cond_signal(cond);
pthread_mutex_unlock(&mutex);

5.6 简单案例

//thread_safe_cond.cpp
#include <stdio.h>
#include <unistd.h>
#include <pthread.h>

#define THREAD_COUNT 2

int g_bowl = 0;

pthread_mutex_t g_tex; //互斥锁
pthread_cond_t consume_cond; //消费者条件变量
pthread_cond_t product_cond; //生产者条件变量

void* EatNoddle(void* arg)
{
	pthread_detach(pthread_self()); //分离掉

	(void)arg;
	while (1)
	{
		pthread_mutex_lock(&g_tex);
		while (g_bowl <= 0)
		{
			pthread_cond_wait(&consume_cond, &g_tex);//等待
		}
		g_bowl--;
		printf("Eat: %p ---> g_bowl:%d\n", pthread_self(), g_bowl);
		pthread_mutex_unlock(&g_tex);

        pthread_cond_signal(&product_cond); //通知做面的人
    }
    return NULL;
}

void* MakeNoddle(void* arg)
{
    pthread_detach(pthread_self()); //分离掉

    (void)arg;
    while (1)
    {
        pthread_mutex_lock(&g_tex);
        while(g_bowl > 0)
        {
            pthread_cond_wait(&product_cond, &g_tex);//等待
        }
        g_bowl++;
        printf("Make: %p ---> g_bowl:%d\n", pthread_self(),g_bowl);
        pthread_mutex_unlock(&g_tex);

        pthread_cond_signal(&consume_cond); //通知吃面的人
    }
    return NULL;
}

int main()
{
    pthread_mutex_init(&g_tex, NULL);
    pthread_cond_init(&consume_cond, NULL);
    pthread_cond_init(&product_cond, NULL);

    pthread_t tid;
    for(int i = 0; i < THREAD_COUNT; i++)
    {
        int ret = pthread_create(&tid, NULL, EatNoddle, NULL);
        if (ret < 0)
        {
            perror("pthread_create");
            return -1;
        }

        ret = pthread_create(&tid, NULL, MakeNoddle, NULL);
        if (ret < 0)
        {
            perror("pthread_create");
            return -1;
        }
    }

    while (1)
    {
        sleep(1);
    }

    pthread_mutex_destroy(&g_tex);
    pthread_cond_destroy(&consume_cond);
    pthread_cond_destroy(&product_cond);
    return 0;
}
  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值