Linux---多线程(上)

一、线程概念

  • 线程是比进程更加轻量化的一种执行流 / 线程是在进程内部执行的一种执行流
  • 线程是CPU调度的基本单位,进程是承担系统资源的基本实体

在说线程之前我们来回顾一下进程的创建过程,如下图

那么以进程为参考,我们该如何去设计创建一个线程呢?
线程不止一个,就注定它也需要被管理,即先描述在组织,即线程需要有一个TCB(和PCB类似),同时线程也是执行流,也需要被调度运行,被管理,所以我们还需要给它设计一系列的算法,然后还得让它和进程联系起来,这样很麻烦

那有没有更简单的做法呢?
由于线程同进程一样也是一个执行流,那么管理进程用的一些信息,线程也应该需要,只不过线程更加起轻量化而已(这个后面会有具体说明),所以进程的结构体和管理用的内核数据结构对于线程也应该同样适用,所以我们可以选择复用进程的代码和数据结构,轻松的完成任务,并且也不用考虑进程和线程的耦合问题,因为它们处在同一个体系框架下
(上面介绍的是Linux的做法,当然也有OS是将进程和线程分开来设计的)

具体如下

但是现在又出现了一个问题,如何看待进程???根据上面这张图,我们会发现进程和线程似乎一样了,都有数据结构和各自的代码和数据。下面我们来进一步理解进程

感性的理解进程和线程

我们可以将进程想象成一个家庭,线程则是家庭中的人,进程的任务就是将这个家变得越来越好,所以家中的每个人都要分工合作干好各自的事,每个家庭都具有独立性(进程独立性),即你生活的好不好跟你的邻居没太大关系,但是你们可能会有交集(进程间通信),之前我们讲的进程可以看成是家中只有一个人的情况。


下面写一段代码(不用管创建线程的函数,后面会讲,主要是观察现象)

#include <iostream>
#include <pthread.h>
#include <unistd.h>
//新线程
void *Threadroutine(void *args)
{char *p = (char *)args;while (1){std::cout << "I am a new thread : " << p << ", pid : " << getpid() << std::endl;sleep(1);}return nullptr;
}int main()
{//已经有进程了pthread_t tid;pthread_create(&tid, nullptr, Threadroutine, (void *)"thread1");//主线程while (1){std::cout << "I am a main thread, pid : " << getpid() << std::endl;sleep(1);}return 0;
}

首先确实有两个执行流在循环打印语句,并且它们的进程pid相同,但是它们的LWP(light weight process)不同,即它们是线程(在Linux中线程的底层是轻量级进程),所以OS在调度时看的是LWP,当然我们会发现有一个线程的LWP和PID是一样的,它就是主线程。(如果你看到乱序的打印也是正常的,因为OS如何调度两个线程是未知的)

而由于线程共享同一个进程地址空间,所以线程间的共享资源很多,也更容易通信(不保证安全)

#include <iostream>
#include <pthread.h>
#include <unistd.h>int cnt = 0;
void *Threadroutine(void *args)
{char *p = (char *)args;while (1){std::cout << "I am a new thread : " << p << ", pid : " << getpid() << " cnt:" << cnt << std::endl;cnt++;sleep(1);}return nullptr;
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, Threadroutine, (void *)"thread1");sleep(1);pthread_t tid1;pthread_create(&tid1, nullptr, Threadroutine, (void *)"thread2");sleep(1);pthread_t tid2;pthread_create(&tid2, nullptr, Threadroutine, (void *)"thread3");sleep(1);while (1){std::cout << "I am a main thread, pid : " << getpid() << " cnt:" << cnt << std::endl;sleep(1);}return 0;
}

注意:打印乱序是正常现象,因为Threadroutine是不可重入函数,但是我们重入了。但是这不妨碍我们能看到cnt在不断增加的,并且每个线程都能看到,也就是说cnt这个全局变量对于线程来说是共享的。

如何理解线程更加轻量化???

1、线程的创建更加简单

2、线程的切换更加高效

  • 要修改的寄存器少了---因为线程中有很多数据是一样的,比如存放页表地址的寄存器就不用修改,因为它们共用一个页表
  • 不需要重新更新cache(缓存)---根据局部性原理,在执行某条语句之后,更有可能执行它的上下文中的代码,所以我们会提前将它附近的代码和数据加载到缓存(称为热数据),来提高CPU效率,对于线程来说这样的热数据大概率是有效的,而对于进程来说,则是基本无效的,需要重新加载

(注意:这里谈论的线程切换是指在同一个进程中的线程的切换,不同进程的线程切换还是属于进程切换)

二、进一步理解进程地址空间

三、线程的优缺点+资源+异常

 1、优点

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

2、缺点

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

3、资源

线程共享进程数据,但也拥有自己的一部分数据:线程ID、硬件上下文、栈、errno、信号屏蔽字、调度优先级

进程的多个线程共享同一地址空间,因此Text Segment、Data Segment都是共享的,如果定义一个函数,在各线程中都可以调用,如果定义一个全局变量,在各线程中都可以访问到,除此之外,各线程还共享以下进程资源和环境:文件描述符表、每种信号的处理方式(SIG_ IGN、SIG_ DFL或者自定义的信号处理函数)、当前工作目录、用户id和组id

(注意:信号的pending位图、block位图都是各自私有的)
 

4、线程异常

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

四、线程控制---相关函数接口介绍

1、线程创建

功能:创建一个新的线程
参数:

  • thread:返回线程ID
  • attr:设置线程的属性,attr为NULL表示使用默认属性
  • start_routine:是个函数地址,线程启动后要执行的函数
  • arg:传给线程启动函数的参数

返回值:成功返回0;失败返回错误码

#include <iostream>
#include <pthread.h>
#include <unistd.h>void* ThreadRoutine(void * args)
{while(1){std::cout << "I am a new thread" << std::endl;sleep(1);}
}
int main()
{pthread_t tid;pthread_create(&tid,nullptr,ThreadRoutine,nullptr);while(1){std::cout << "I am a main thread" << std::endl;sleep(1);}return 0;
}

 

1)给线程传参的问题

#include <iostream>
#include <functional>
#include <string>
#include <vector>
#include <pthread.h>
#include <unistd.h>
#include <time.h>using func_t = std::function<void()>;
class ThreadDate
{
public:std::string _name;u_int64_t _createtime;func_t _f;public:ThreadDate(std::string name, u_int64_t createtime, func_t f): _name(name), _createtime(createtime), _f(f){}
};void Print()
{std::cout << "我正在执行某个任务" << std::endl;
}//函数的参数为void*,即可以传任意类型的指针,我们可以把结构体对象当参数传进去
void *ThreadRoutine(void *args)
{ThreadDate *ptd = static_cast<ThreadDate *>(args);while (1){std::cout << "new thread name: " << ptd->_name << " , create time: " << ptd->_createtime << std::endl;ptd->_f();sleep(1);}
}int main()
{std::vector<pthread_t> v;for (int i = 0; i < 5; i++){char name[64] = {0};sprintf(name, "%s-%d", "thread", i);ThreadDate *p = new ThreadDate(name, (u_int64_t)time(nullptr), Print);pthread_t tid;pthread_create(&tid, nullptr, ThreadRoutine, (void *)p);v.push_back(tid);}sleep(3);std::cout << "thread id: ";for (auto x : v){std::cout << x << " ";}std::cout << std::endl;while (1){std::cout << "I am a main thread" << std::endl;sleep(1);}return 0;
}

我们确实能通过函数的参数将我们想要的数据(放在结构体对象中)传给线程,并且也可以传函数,同理该线程的返回值也是一样(这个后面会演示)

打印出来的tid显然和LWP不相同,它具体是什么呢?(后面会说,这里只是抛出问题)。

也可以通过上面这个函数获取线程自身的tid,注意不是LWP!!!

2)线程异常问题

显然在第四个线程出现异常,收到信号后,整个进程都退出了,也说明线程的健壮性比较差

2、线程退出

原型:void pthread_exit(void *value_ptr)

功能:线程终止
参数:

  • value_ptr:value_ptr不要指向一个局部变量。

返回值:无返回值,跟进程一样,线程结束的时候无法返回到它的调用者(自身)

#include <iostream>
#include <pthread.h>
#include <unistd.h>void* ThreadRoutine(void* args)
{int cnt = 5;while(cnt--){std::cout << " I am a new thread " << std::endl;sleep(1);}pthread_exit(nullptr); // 终止线程// return nullptr; // 也可以终止线程// exit(1); // 注意:该函数是用来结束进程的!!!
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, ThreadRoutine, nullptr);//...return 0;
}

 我们也可以直接return,这样也是能终止线程的。

3、线程等待

线程退出默认也是需要被等待的(就像进程一样)

  • 线程退出,没有等待,会导致类似进程的僵尸问题
  • 线程退出,主线程也需要获取新线程的返回值

原型:int pthread_join(pthread_t thread, void **value_ptr)

功能:等待线程结束
参数

  • thread:线程ID
  • value_ptr(输出型参数):它指向一个指针,该指针指向线程的返回值,可以接收任意类型的指针

返回值:成功返回0,失败返回错误码

#include <iostream>
#include <pthread.h>
#include <unistd.h>void* ThreadRoutine(void* args)
{int cnt = 5;while(cnt--){std::cout << " I am a new thread " << std::endl;sleep(1);}pthread_exit((void*)"thread end"); //结束线程// return (void*)"thread end";
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, ThreadRoutine, nullptr);void*ret = nullptr;int n = pthread_join(tid,&ret);std::cout << "main thread, n: " << n << std::endl;std::cout << "new thread return val: " << (char*)ret << std::endl;return 0;
}

和线程创建时传给线程的参数一样,这里的返回值可以是任意类型的指针(可以指向结构体,该结构体中可以存放任何你想通过线程得到的数据,这个就不演示了,类比线程创建即可)

这里简单说明一下:为什么进程退出时既关心是否正常退出,又关心异常问题,但是线程出现异常我们并不关心?因为线程一旦异常,会导致进程整个挂掉,所以线程的异常就没必要关心了

4、线程分离

当主线程不关心线程的的返回结果时,我们可以将线程设置为分离状态,这样该线程结束后就会自动被OS回收,不需要主线程在等待了

int pthread_detach(pthread_t thread)

功能:分离线程

参数:

  • thread:线程ID

返回值:成功返回0,失败返回错误码

#include <iostream>
#include <pthread.h>
#include <unistd.h>void* ThreadRoutine(void* args)
{// pthread_detach(pthread_self()); // 可以在线程中进行线程分离int cnt = 5;while(cnt--){std::cout << " I am a new thread " << std::endl;sleep(1);}return nullptr;
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, ThreadRoutine, nullptr);pthread_detach(tid);sleep(1);int n = pthread_join(tid,nullptr);std::cout << "main thread, n: " << n << std::endl;return 0;
}

显然线程等待失败,这里的线程分离,也可以在创建的线程中使用 (注意:线程分离后,出现异常还是会导致整个进程挂掉)

5、线程取消

原型:int pthread_cancel(pthread_t thread)

功能:取消一个执行中的线程
参数:

  • thread:线程ID

返回值:成功返回0,失败返回错误码

#include <iostream>
#include <pthread.h>
#include <unistd.h>void* ThreadRoutine(void* args)
{while(true){std::cout << " I am a new thread " << std::endl;sleep(1);}return nullptr;
}int main()
{pthread_t tid;pthread_create(&tid, nullptr, ThreadRoutine, nullptr);// pthread_detach(tid);sleep(5);int n = pthread_cancel(tid);std::cout << "main thread, cancel return: " << n << std::endl;void*ret = nullptr;n = pthread_join(tid, &ret);std::cout << "main thread, join return: " << n << " ret: " << (long long)ret << std::endl;return 0;
}

(注意:如果线程在pthread_cancel之前终止,那么该函数调用失败,返回错误码) 

thread线程以不同的方法终止,通过pthread_join得到的终止状态是不同的,总结如下
1. 如果thread线程通过return返回,value_ ptr所指向的单元里存放的是thread线程函数的返回值。
2. 如果thread线程被别的线程调用pthread_ cancel异常终掉,value_ ptr所指向的单元里存放的是常数PTHREAD_ CANCELED [#define PTHREAD_CANCELED ((void *) -1)]
3. 如果thread线程是自己调用pthread_exit终止的,value_ptr所指向的单元里存放的是传给pthread_exit的参数
4. 如果对thread线程的终止状态不感兴趣,可以传NULL给value_ ptr参数

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

五、理解线程库

在语言的角度:其实C++11中的线程库就是对原生线程库的封装,其他语句同理

注意:__thread只能修饰内置类型,自定义类型不行,在C++11中可以用thread_local对自定义类型进行修饰

六、模拟实现C++线程库(简易版)

#pragma once
#include <iostream>
#include <functional>
#include <string>
#include <pthread.h>using func_t = std::function<void()>; // 该函数类型可以按照需求改变
class thread
{
public:thread(std::string name, func_t f): _tid(0), _name(name), _isrunning(false), _fun(f){}// 注意,如果是非静态成员,则会多一个this作为参数(c++语法)static void *ThreadRoutine(void *args){thread *t = static_cast<thread *>(args);t->_fun(); // 要想访问类成员,要传类对象return nullptr;}bool Start(){int n = pthread_create(&_tid, nullptr, ThreadRoutine, this);if (n == 0){_isrunning = true;return true;}return false;}bool Join(){if (!_isrunning)return true;int n = pthread_join(_tid, nullptr);if (n == 0){_isrunning = false;return true;}return false;}std::string getname(){return _name;}bool IsRunning(){return _isrunning;}~thread(){}private:pthread_t _tid;std::string _name;bool _isrunning;func_t _fun;
};//进阶---用模板
template <class T>
using func_t = std::function<void(T)>;template <class T>
class thread
{
public:thread(std::string name, func_t<T> f, T data): _tid(0), _name(name), _isrunning(false), _fun(f),_data(data){}// 注意,如果是非静态成员,则会多一个this作为参数(c++语法)static void *ThreadRoutine(void *args){thread *t = static_cast<thread *>(args);t->_fun(t->_data); // 要想访问对象,要传递对象return nullptr;}bool Start(){int n = pthread_create(&_tid, nullptr, ThreadRoutine, this);if (n == 0){_isrunning = true;return true;}return false;}bool Join(){if (!_isrunning)return true;int n = pthread_join(_tid, nullptr);if (n == 0){_isrunning = false;return true;}return false;}std::string getname(){return _name;}bool IsRunning(){return _isrunning;}~thread(){}private:pthread_t _tid;std::string _name;bool _isrunning;func_t<T> _fun;T _data;// 如果需要也可以加一个成员变量存储线程的结果
};

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/738197.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

paddle的版面分析的环境搭建及使用

一、什么是版面分析 版面分析技术&#xff0c;主要是对图片形式的文档进行版面分析&#xff0c;将文档划分为文字、标题、表格、图片以及列表5类区域&#xff0c;如下图所示&#xff1a; 二、应用场景 2.1 合同比对 2.2 文本类型划分 2.3 通用文档的还原 版面分析技术可将以…

论文阅读FCN-Transformer Feature Fusion for PolypSegmentation

本文提出了一种名为Fully Convolutional Branch-TransFormer (FCBFormer)的图像分割框架。该架构旨在结合Transformer和全卷积网络&#xff08;FCN&#xff09;的优势&#xff0c;以提高结肠镜图像中息肉的检测和分类准确性。 1&#xff0c;框架结构&#xff1a; 模型采用双分…

【Python】牛客网—软件开发-Python专项练习

专栏文章索引&#xff1a;Python 1.&#xff08;单选&#xff09;下面哪个是Python中不可变的数据结构&#xff1f; A.set B.list C.tuple D.dict 可变数据类型&#xff1a;列表list[ ]、字典dict{ }、集合set{ }(能查询&#xff0c;也可更改)数据发生改…

Golang 开发实战day03 - Arrays Slices

Golang 教程03 - Arrays&#xff0c;Slices Go语言中的数组和切片都是用于存储数据的类型&#xff0c;但它们之间存在一些重要的区别。了解这些区别对于有效地使用它们至关重要。 1. Arrays 数组 1.1 定义 数组是一种固定大小的数据结构&#xff0c;用于存储相同类型的值。…

广西省行政村边界shp数据/广西省乡镇边界/广西省土地利用分类数据/径流分布

广西壮族自治区&#xff0c;地处中国南部&#xff0c;北回归线横贯中部。南北以贺州——东兰一线为界&#xff0c;此界以北属中亚热带季风&#xff0c;以南属南亚热带季风。 数据范围&#xff1a;全国行政区划-行政村界 数据类型&#xff1a;面状数据&#xff0c;全国各省市县…

1月笔记本电脑行业分析:多品牌下滑但ThinkPad逆势增长!

2024年1月&#xff0c;笔记本行业市场格局出现较大的变化。长期在京东平台保持头部联想和惠普&#xff0c;被ThinkPad挤下&#xff08;虽然是联想旗下品牌&#xff09;&#xff0c;排名掉至第二和第三。ThinkPad以超2.7亿的月销售额成绩拿下第一&#xff0c;市占比16%。 与去年…

java SSM农产品订购网站系统myeclipse开发mysql数据库springMVC模式java编程计算机网页设计

一、源码特点 java SSM农产品订购网站系统是一套完善的web设计系统&#xff08;系统采用SSM框架进行设计开发&#xff0c;springspringMVCmybatis&#xff09;&#xff0c;对理解JSP java编程开发语言有帮助&#xff0c;系统具有完整的源代码和数据库&#xff0c;系统主要采…

算法-贪心-112. 雷达设备

题目 假设海岸是一条无限长的直线&#xff0c;陆地位于海岸的一侧&#xff0c;海洋位于另外一侧。 每个小岛都位于海洋一侧的某个点上。 雷达装置均位于海岸线上&#xff0c;且雷达的监测范围为 d&#xff0c;当小岛与某雷达的距离不超过 d 时&#xff0c;该小岛可以被雷达覆…

大语言模型:Large Language Models Are Human-Level Prompt Engineers概述

研究内容 如何通过prompt&#xff0c;在不进行微调大语言模型的前提下&#xff0c;增加大语言模型的表现 研究动机 prompt非常有用&#xff0c;但是人工设置的非常不自然&#xff1b;因此提出了要自动使用大语言模型自己选择prompt&#xff1b;取得了很好的效果。 作者主要…

python实现生成树

生成树 生成树&#xff08;Spanning Tree&#xff09;是一个连通图的生成树是图的极小连通子图&#xff0c;它包含图中的所有顶点&#xff0c;并且只含尽可能少的边。这意味着对于生成树来说&#xff0c;若砍去它的一条边&#xff0c;则会使生成树变成非连通图&#xff1b;若给…

Git LFS【部署 01】Linux环境安装git-lfs及测试

Linux系统安装git-lfs及测试 1.下载2.安装3.测试4.总结 Git LFS&#xff08;Large File Storage&#xff09;是一个用于Git版本控制系统的扩展&#xff0c;它专门用来管理大型文件&#xff0c;如图像、音频和视频文件。 1.下载 安装包下载页面&#xff1a;https://github.com/…

web3D三维引擎(Direct3D、OpenGL、UE、U3D、threejs)基础扫盲

Hi&#xff0c;我是贝格前端工场的老司机&#xff0c;本文介绍文web3D的几个引擎&#xff0c;做个基础扫盲&#xff0c;如果还不能解决问题&#xff0c;可以私信我&#xff0c;搞私人订制呦。 三维引擎是指用于创建和渲染三维图形的软件框架。它们通常提供了图形处理、物理模拟…

AIGC: 2 语音转换新纪元-Whisper技术在全球客服领域的创新运用

背景 现实世界&#xff0c;人跟人的沟通相当一部分是语音沟通&#xff0c;比如打电话&#xff0c;聊天中发送语音消息。 而在程序的世界&#xff0c;大部分以处理字符串为主。 所以&#xff0c;把语音转换成文字就成为了编程世界非常普遍的需求。 Whisper 是由 OpenAI 开发…

PostgreSQL索引篇 | GIN索引 (倒排索引)

GIN索引 倒排索引 PostgreSQL版本为8.4.1 &#xff08;本文为《PostgreSQL数据库内核分析》一书的总结笔记&#xff0c;需要电子版的可私信我&#xff09; 索引篇&#xff1a; PostgreSQL索引篇 | BTreePostgreSQL索引篇 | GiST索引PostgreSQL索引篇 | Hash索引PostgreSQL索引…

汽车软件市场迅猛扩张,Perforce Helix Core与Helix IPLM助力汽车软件开发的版本控制及IP生命周期管理

汽车软件世界正处于持续变革和转型之中。从自动驾驶汽车到电动汽车和先进的驾驶辅助系统&#xff0c;汽车软件的集成度和复杂性不断提升。 据美国电气与电子工程师协会的研究&#xff0c;如今大多数汽车都集成了超过1亿行代码&#xff0c;而仅仅十年前&#xff0c;这种水平的汽…

软件杯 垃圾邮件(短信)分类算法实现 机器学习 深度学习

文章目录 0 前言2 垃圾短信/邮件 分类算法 原理2.1 常用的分类器 - 贝叶斯分类器 3 数据集介绍4 数据预处理5 特征提取6 训练分类器7 综合测试结果8 其他模型方法9 最后 0 前言 &#x1f525; 优质竞赛项目系列&#xff0c;今天要分享的是 &#x1f6a9; 垃圾邮件(短信)分类算…

ubuntu 18.04安装教程(详细有效)

文章目录 一、下载ubuntu 18.04镜像二、安装ubuntu1. 点击下载好的Vmware Workstation&#xff0c;点击新建虚拟机&#xff0c;选择 “自定义(高级)”&#xff0c;之后下一步。2. 默认配置&#xff0c;不需要更改&#xff0c;点击下一步。3. 选择 “安装程序光盘映像文件(iso)(…

Windows环境部署Hadoop-3.3.2和Spark3.3.2

目录 一、Windows环境部署Hadoop-3.3.2 1.CMD管理员解压Hadoop压缩包 2.配置系统环境变量 3.下载hadoop winutils文件 4.修改D:\server\hadoop-3.3.2\etc\hadoop目录下的配置文件 (1)core-site.xml (2)hdfs-site.xml (3)mapred-site.xml (4)yarn-site.xml (5)workers…

Oracle 层级查询(Hierarchical Queries)

如果一张表中的数据存在分级&#xff08;即数据间存在父子关系&#xff09;&#xff0c;利用普通SQL语句显示数据间的层级关系非常复杂&#xff0c;可能需要多次连接才能完整的展示出完成的层级关系&#xff0c;更困难的是你可能不知道数据到底有多少层。而利用Oracle的层级查询…

VSCode单机活动栏图标无法收起

如果活动栏为展开状态&#xff0c;单击活动栏图标可以正常收起&#xff0c;但无法通过再次单击打开&#xff0c;解决方案如下&#xff1a; 设置->工作台->外观&#xff1a; Activity Bar:Icon Click Behavior: 切换为默认的toggle