Linux操作系统学习之---线程控制

一.Linux下的线程设计:

1. 轻量级进程(LWP):

Linux系统下并不存在真正的线程 , 而是使用进程模拟出来的更小的进程(小主要体现在额外占用的资源少) .

2.Linux的线程库 :

一个矛盾的地方在于 : Linux系统底层没有线程 , 但是上层使用者需要线程这个概念来服务于编程.

  • Linux的第三方库 pthread.h将底层的轻量化线程进行封装 , 对上层展现出线程风格的操作的函数接口运行机制
  • 由于是第三方库 , 在编译时需要手动指明库名称 , 比如gcc的选项 -llibpthread.so ,以免去链接时错误.

二.线程控制(基础):

1.线程创建:

pthread函数声明:

int pthread_create(*pthread_t *restrict thread,

const pthread_attr_t *restrict attr,

void *(*start_routine)(void *),

void *restrict arg);

  • 参数一: thread : 输出型参数 , 线程id的指针.
  • 参数二:attr : 用来规定线程各种性质的参数,此处传空指针使用系统默认即可.
  • 参数三: start_routine : 一个函数指针, 规定线程运行逻辑.
  • 参数四: arg: 参数三函数指针的参数.

线程创建的简单代码

  • pthread_create函数调用时会分配一个线程id并修改到第一个参数里
  • 当子线程创建 , main函数里的主线程还会继续向下执行.
  • 此时就是天然的并发执行.
c++ 复制代码
void* Routine(void* p)
{
    while(true)
    {
        cout << "新线程运行,id=" << pthread_self() <<  endl;
        sleep(1);
    }
    cout << "新线程退出 , id = " << pthread_self() << endl;
    return nullptr;
}
int main()
{
    pthread_t tid = 0; 
    pthread_create(&tid , nullptr , Routine,(void*)"666" );

    int count = 5;
    while(count--)
    {
        sleep(1);
    }
    return 0;
}

2.线程终止:

错误方式:

此前在不涉及多线程的编程时实践里 , exit是一个终止程序的不错选择 , 但是切忌在线程中使用

  • 一个主线程创建的多个线程在Linux里都属于一个进程 , 因为共享一个进程的地址空间 .
  • exit函数的作用是让操作系统向进程发送退出信号 , 因此会让整个进程退出 , 从而让基于进程存在的线程也全部终止.

正确方式

pthread_self函数声明
pthread_t pthread_self(void);

  • 返回一个phtread_t类型的数字---线程自己的id .

pthread_exit函数声明

*[[noreturn]] void pthread_exit(void retval);

  • 终止调用的线程自己.

pthread_cancel函数声明:
int pthread_cancel(pthread_t thread);

  • 可以向pthread_cancel函数里传递线程id达到停止线程的作用.
  • 但一般不建议使用

最常见也是最推荐的线程退出方法 : return返回 !!!

但是注意 , Linux下的一个进程的多个线程共享资源 , 因此主线程return会导致其他线程跟着退出.


3.线程等待 :

  • 线程等待的库函数叫做pthread_join , 和进程等待的 waitpid不同 .
  • 一个子线程退出后资源默认不会释放 , 需要主线程进行等待.
  • join一词的含义是联合,合并 , 十分形象 , 因为线程退出时并未释放进程的资源 , 仅仅是将自己的退出信息或者运行结果返回给主线程 .
  • 这就好像家庭 (进程) 里的成员 (线程) 一天的劳碌后回到家上交了一天的工钱 (退出信息) .

pthread_join函数的声明:

*int pthread_join(pthread_t thread, void *retval);

  • 等待成功时,返回值为0 ; 失败时返回值为-1;
  • 参数一就是被等待的子线程id .
  • 参数二是一个二级指针 .

!为啥是二级指针?\]- * 下面这个例子中在Routine函数的**局部域**里定义了返回值 . * 为了避免当前函数调用结束后**栈上的局部变量** 销毁 , 因此采用动态开辟的**堆空间保存变量**. * 此时函数体内传入`pthread_exit函数`的就是**一级指针**了. * 外部的`pthread_join函数`想要接收到接收到这个指针 , 所传入的输出型参数就得是**二级指针**.

c++ 复制代码
void* Routine(void* p)
{
    int* ret_val = new int(10);
    pthread_exit((void*)ret_val);
    while(true)
    {
        cout << "新线程运行,id=" << pthread_self() <<  endl;
        sleep(1);
    }
    cout << "新线程退出 , id = " << pthread_self() << endl;
    return nullptr;
}
int main()
{
    pthread_t tid = 0; 
    pthread_create(&tid , nullptr , Routine,(void*)"666" );

    void* ret = nullptr;
    while(true)
    {
        if(pthread_join(tid,&ret) == 0)
        {
            cout << "获取子线程退出信息 : " << *(int*)ret << endl;
        }
        sleep(1);
    }
    return 0;
}

4.线程分离 :

线程分离的方式和效果:

  • 线程分离的库函数是pthread_detatch可以让子线程终止时自动释放资源 , 让主线程彻底放心.
  • 分离后的子线程也无法被等待 , 会导致pthread_join的返回值直接<0;

pthread_detach函数的声明
int pthread_detach(pthread_t thread);

  • 分离成功时, 返回值为0 ; 分离失败,返回值为错误码
  • 参数一是需要分离线程的线程号
  1. 子线程刚开始运行就调用pthread_detach来进行分离 , 此后一直死循环运行.
  2. 主线程先sleep几秒(防止子线程还没开始运行父线程就结束 )后调用pthread_join函数尝试阻塞(函数默认特性)等待子线程 .
  3. 结果会发现子线程没有退出 , 而主线程等待失败 , 运行结束 . 也连带着子线程结束了.
c++ 复制代码
void* routine(void* input)
{
    pthread_detach(pthread_self());
    cout << "子线程detach!!!\n";
    while (true)
    {
        cout << "子线程仍在运行...\n";
        sleep(1);
    }
    
}

int main(int argc, char const *argv[])
{
    pthread_t tid = 0;
    pthread_create(&tid,nullptr,routine,(void*)"666");

    sleep(5);
    if(pthread_join(tid,nullptr) != 0)
        cout << "等待主线程失败" << endl;

    return 0;
}

线程分离的原因:

通常来说 , 云服务器系统的主线程启动之后就永远不退出 , 而是不停的派发任务 . 但是pthread_join函数会导致主线程的阻塞 . 因此派发任务阻塞等待子线程 只能二选一 , 大部分情况下选择前者.

这也类似于进程里学到的 SIGCHLD 信号的使用方式 : signal(SIGCHLD , SIG_IGN) , 让子进程自己退出时不通知父进程 , 自己销毁资源.


三.线程控制(plus)

1.复杂参数传递(对象)和返回值 :

pthread_create函数的第四个参数是一个void*类型的指针 , 因此灵活性很高 . 不仅仅可以传递简单的数字或者字符串 , 还可以传递一个对象 !!! 返回值也可以是对象的地址!!!

代码设计思路

  • factorial执行阶乘计算任务.
  • result类使用factorial类的计算结果初始化自己 , 承担存放结果的任务.
  • 线程逻辑的work函数中 , 把结果以堆空间的形式保存起来并返回其地址.
  • 在主线程中使用thread_join的输出型参数来获取返回的地址(指针的指针,二级指针)
  • 千万记得在最后delete factorial 和 result 两个类对象的资源!!!
c++ 复制代码
class factorial
{
public:
    factorial(int x = 0)
    :_x(x)
    {
    }
    int process()
    {
        return _process(_x);
    }
private:
    int _process(int x)
    {
        if(x == 1) return 1;
        return x * _process(x-1);
    }
    int _x;
};

class result
{
public:
    result(int ret = 0)
    :_result(ret)
    {}
    int GetResul() {return _result; }
private:
    int _result;
};

void* work(void* input)
{
    result* pret = new result(((factorial*)input)->process());
    return pret;
}

int main()
{
    pthread_t tid = -1;

    factorial* pfa = new factorial(4);
    pthread_create(&tid,nullptr,work,pfa);

    void* p = nullptr;
    pthread_join(tid,&p);

    cout << "主线程收到结果 : " << ((result*)p)->GetResul() << endl;

    delete(pfa);
    delete((result*)p);
    return 0;
}

2.多线程创建:

简单的多线程 :

下面的代码逻辑里 , 循环创建多个线程 , 随后循环回收多个线程.

代码:
c++ 复制代码
void* routine(void* input)
{
    string st((char*)input);
    int cnt = 10;
    while (cnt--)
    {
        cout << st << endl;
        sleep(5);
    }
    return nullptr;
}

int main()
{
	//使用vector存放线程id
    vector<pthread_t> v;
    v.resize(10);
    //循环创建线程(自始至终,这是主线程上下文里的循环)
    for(int i = 0 ; i<10 ; i++ )
    {
        char ch[256];
        snprintf(ch,sizeof(ch),"%d 号子线程", i);
        pthread_create(&v[i],nullptr,routine,(void*)ch);
    }
    //让主线程回收子线程.
    sleep(5);
    for (size_t i = 0; i < 10; i++)
    {
        pthread_join(v[i],nullptr);
    }

    return 0;
}
现象:
原因(一个小实验) :
  • 有没有想过一个问题 , for循环里我们常用的那个变量i会在每一轮变量都新建一个吗???
    答案是否定的!!!
  • char ch[256]是一个栈上 开辟的数组 , 所有线程共享 , 而且在循环期间也不刷新 .
  • 因此随后产生的线程会立马覆盖ch的值 , 如果前面创建的线程手慢了,就可能读取被篡改后的数据!!!
  • 当然,下面还是做一个简单的验证

验证代码:

c++ 复制代码
int main()
{
	for (size_t i = 0; i < 10; i++)
    {
        char ch[256];
        printf("%p\n",ch);
    }
    cout << "栈空间地址全部相同,没有新开辟,直接复用了!!!\n";
    cout << "###############" << endl;
    for (size_t i = 0; i < 10; i++)
    {
        char* ch = new char[256];
        printf("%p\n",ch);
    }
}

运行结果 :

  • 即便是循环内定义的数据 , 只要处在栈空间上 , CPU运行时直接复用(估摸着偷偷保存在寄存器了O(∩_∩)O哈哈~)!!!
  • 如果定义的数据是动态开辟的 , 那虚拟地址必须得是全新 , 毕竟不是同一份资源.
bash 复制代码
an@mycloud:~/code-in-linux/c++_linux/9_thread$ ./code #运行程序
0x7fffe20c9f60
0x7fffe20c9f60
0x7fffe20c9f60
0x7fffe20c9f60
0x7fffe20c9f60
0x7fffe20c9f60
0x7fffe20c9f60
0x7fffe20c9f60
0x7fffe20c9f60
0x7fffe20c9f60
#栈空间地址全部相同,没有新开辟,直接复用了!!!
###############
0x62b234f536c0
0x62b234f537d0
0x62b234f538e0
0x62b234f539f0
0x62b234f53b00
0x62b234f53c10
0x62b234f53d20
0x62b234f53e30
0x62b234f53f40
0x62b234f54050
#堆空间地址全部不相同,都是新开辟的!!!

解决竞态问题:

既然栈区的空间被CPU复用 , 被线程共享 , 那就是用堆上的空间!!!
注意 , 尽管线程之间同样共享堆空间 , 但是在这里的循环内他们拿到的是不同堆空间的地址, 因此不会冲突.

代码:
c++ 复制代码
//.......
	for(int i = 0 ; i<10 ; i++)
	{
		//错误逻辑(栈上的空间每个线程都能看到)
		//char ch[256];
        //snprintf(ch,sizeof(ch),"%d 号子线程", i);
		
		//正确逻辑(堆上的空间独一份)
         char* ch = new char[256];
         snprintf(ch,256,"%d 号子线程", i);
	}
        
//..........
正确结果:

四.拨开迷雾,直入底层

三个问题:

  • ptread_t 类型的大数字是啥 , 真的是线程id吗???
  • 主线程调用的pthread_join函数是如何拿到子线程函数的void*返回值???
  • pthread_detach函数调用时干了啥 , 操作系统如何判断出一个分离的线程???

前置理解 :

  • 实际情况 : Linux操作系统内部本身不存在线程的概念 , 而是运用轻量级进程来模拟实现.
  • 困境 : 上层开发者需要线程这样的概念.
  • 解决方案 : 在用户层封装一个库pthread.h , 对上层提供符合线程逻辑的函数接口.

库的加载 :

  • 我们的可执行程序动态库文件 都是ELF格式的文件 , 在程序运行时要加载到内存.
  • 加载到内存时主要就是磁盘上逻辑地址到内存中虚拟地址的转换 , 并建立页表的映射 ,由进程自己保存.
  • 其中 , 动态库文件会加载到共享区 , 程序运行时就可以通过GOT表的偏移量和库文件在共享区里的起始地址动态计算出实际虚拟地址 , 从而访问库代码.
  • libpthread.so也是动态库 , 也会加载到共享区!!!

库对线程的管理:

  • 内核里没有线程的概念 , 而libpthread.so就是来打掩护的 .
  • libpthread.so库内部就需要用struct将上层用户看到的线程概念全部描述起来 , 从而进行管理 . 他叫TCB(thread control block).
  • 这个TCB , Linux内核中叫struct pthread , 其中就有如下信息:
    • 线程id
    • 线程栈地址 : 每个线程创建时必须有私有的栈空间.
    • 线程状态 : 如joinable
    • 线程返回值 : 如 void* result字段.

pthread_create的执行过程:

当我们在代码中调用pthread_create(&tid, ..., routne, arg)

  1. 在共享区创建一块较大的内存 , 用来存放多个TCB(因为可能不止一个线程),即管理块

  2. 这块管理块就是内核里维护的struct pthread结构体对象 , 内部还会有一个指针指向附近的私有栈空间.

  3. 库函数将我们传入的routine(入口函数) 和arg(入口函数参数)保存到这个struct pthread内部的字段 , 如 pd->start_routine = routine , pd->arg = arg

  4. 然后会把生成的线程id写回主线程(通过传入的输出型参数&tid ) , 这个并非真正的线程id , 而是一个地址值 , 而是内核层面轻量级进程的地址 .

  5. 库函数调用系统调用close在内核层面创建轻量级进程 , 并且传入TCB中的私有栈空间 地址和入口函数地址.

  6. 最后由内核来像调度进程一样调度轻量级进程.

新线程LWP的执行与终止:

  1. 新线程调度时 , 首先就去运行libthread.so的入口函数.

  2. 入口函数的运行过程:

    • 找到自己的TCB

    • 从TCB中拿到start_routine 和 arg.

    • 执行用户的传入的函数 void* user_ret = routne(arg)

    • routne函数调用结束 , 返回值user_ret由这个入口函数获取.

    • 入口函数将user_ret设置到TCB的 result字段 , tcb->result = user_result , 这就是上层pthread_join返回值的来源

    • 入口函数对TCB字段is_joinable进行判断;

      • 若为 Detached , 则释放这个TCB的资源, 如私有栈空间.
      • 若为 joinable , 则保留TCB资源 , 正常退出.
  3. 主线程调用pthread_join(tid, &ret)函数

    • tid就是TCB的地址.
    • 通过ret就能深入TCB , 获取tcb->result , 即线程返回值.
    • 最后由pthread_join函数释放线程TCB资源.

小结 :

  1. 上层使用的pthread_t类型的tid本质是线程控制块(TCB)的地址 , 这也是其数值较大的原因

  2. 主线程之所以能获取子线程退出值 , 是因为在用户传入的函数执行完毕后 , 返回值保存到TCB结构体的成员变量里了 . 进而通过pthread_join的第一个TCB结构体地址参数就能访问.

  3. 上层调用的pthread_detach 函数本质就是修改TCB的成员变量is_joinable,轻量级线程在运行结束前会查看这个字段,决定是否释放TCB资源.

相关推荐
Knight_AL4 小时前
Spring AOP 中@annotation的两种写法详解
java·spring
某空m4 小时前
【Android】BottomNavigationView实现底部导航栏
android·java
顾漂亮4 小时前
Spring AOP 实战案例+避坑指南
java·后端·spring
one year.4 小时前
Linux:库制作与原理
linux·运维·服务器
陈苏同学4 小时前
Win11安装 Ubuntu 22.04 子系统 - WSL2 - 安装完迁移到其它盘
linux·运维·ubuntu
SimonKing4 小时前
Mybatis-Plus的竞争对手来了,试试 MyBatis-Flex
java·后端·程序员
光军oi4 小时前
JAVA全栈JVM篇————初识JVM
java·开发语言·jvm
我命由我123455 小时前
PDFBox - PDFBox 加载 PDF 异常清单(数据为 null、数据为空、数据异常、文件为 null、文件不存在、文件异常)
java·服务器·后端·java-ee·pdf·intellij-idea·intellij idea
蓝色土耳其love5 小时前
centos 7.9 安装单机版k8s
linux·运维·服务器·kubernetes·centos