[Linux]------线程控制与互斥
创始人
2024-03-13 13:14:57
0

文章目录

  • 前言
  • 一、进程VS线程
    • 空间共享
  • 二、线程控制
    • POSIX线程库
    • 创建线程
      • 获取线程ID
      • pthread_join
        • 线程异常
        • 第二个参数
    • 线程的局部存储
    • 线程的分离
    • exit()
  • 三、线程的互斥
    • 进城线程间的互斥相关背景概念
    • 互斥量mutex
      • 模拟抢票逻辑
      • 解决问题
      • 互斥量实现原理探究
      • 基于RAII机制锁的模拟实现
  • 四、可重入VS线程安全
    • 概念
    • 常见的线程不安全的情况
    • 常见的线程安全的问题
    • 常见不可重入的情况
    • 常见可重入的情况
    • 可重入与线程安全联系
    • 可重入与线程安全区别
  • 五、常见锁概念
    • 死锁
      • 代码重现死锁问题
    • 死锁的四个必要条件
    • 避免死锁
    • 避免死锁的算法
  • 总结


前言


正文开始!

一、进程VS线程

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

空间共享

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

  • 文件描述符表
  • 美中心好的处理方式(SIG_IGN,SIG_DFL或者自定义的信号处理函数)
  • 当前工作目录
  • 用户id和组id

二、线程控制

POSIX线程库

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

创建线程

void* startRoutine(void* args)
{while(true){cout<<"线程的正在运行..."<pthread_t tid;int n=pthread_create(&tid,nullptr,startRoutine,(void*)"thread1");cout<<"new thread id: "<cout<<"main thread 正在运行..."<

在这里插入图片描述
tid的值为什么这么大呢?—>线程的ID

转为16进制打印后

在这里插入图片描述

获取线程ID

在这里插入图片描述

static void printTid(const char* name,const pthread_t& tid)
{printf("%s 正在运行,id : 0x%x\n",name,tid);
}
void* startRoutine(void* args)
{const char* name=static_cast(args);while(true){printTid(name,pthread_self());sleep(1);}
}
int main()
{pthread_t tid;int n=pthread_create(&tid,nullptr,startRoutine,(void*)"thread1");while(true){printTid("main thread",pthread_self());sleep(1);}pthread_join(tid,nullptr);return 0;
}

在这里插入图片描述
在这里插入图片描述
pthread_t的tid其实就是一个地址!

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
线程的全部实现,并没有全部体现在OS内,而是OS提供执行流,具体的线程结构由库来进行管理。

库可以创建多个线程—>库也要管理线程!---->先描述在组织。

所以库里面就要有struct thread_info{pthread_t tid; void* stack;//私有栈…};

所以给用户
所以pthread_t是我们对应的用户级现成的控制结构体的起始地址!

所以主线程的独立栈结构用的就是地址空间中的栈区
新线程用的栈结构,用的就是库中提供的栈结构!

所以在Linux中,用户级线程库和内核的LWP是1:1的

pthread_join

在这里插入图片描述
就类似与我们之间讲过的fork()函数创建子进程,父进程需要waitpid()等待子进程退出。不等待就会造成内存泄漏等问题。

void* startRoutine(void* args)
{const char* name=static_cast(args);int cnt=10;while(cnt--){printTid(name,pthread_self());sleep(1);}cout<<"线程退出啦..."<pthread_t tid;int n=pthread_create(&tid,nullptr,startRoutine,(void*)"thread1");sleep(5);cout<<"主线程运行结束,正在等待回收线程"<

在这里插入图片描述

线程异常

void* startRoutine(void* args)
{const char* name=static_cast(args);int cnt=5;while(true){printTid(name,pthread_self());sleep(1);if(!(cnt--)){int* p=nullptr;*p=10;//野指针问题}}cout<<"线程退出啦..."<pthread_t tid;int n=pthread_create(&tid,nullptr,startRoutine,(void*)"thread1");while(true){sleep(1);}pthread_join(tid,nullptr);return 0;
}

在这里插入图片描述
整个线程整体异常退出,线程异常=进程异常

线程会影响其他线程的运行—main thread----健壮性较低,鲁棒性

第二个参数

是一个输出型参数,获取新线程退出时候的退出码!
进程退出:

  • 代码跑完,结果正确
  • 代码跑完,结果不正确
  • 异常
    主线程为什么没有获取新线程退出时的信号呢?
    因为线程异常==进程异常
    在这里插入图片描述
    线程退出的方式
  1. return
void* startRoutine(void* args)
{const char* name=static_cast(args);int cnt=5;while(true){printTid(name,pthread_self());sleep(1);if(!(cnt--)){break;}}cout<<"线程退出啦..."<pthread_t tid;int n=pthread_create(&tid,nullptr,startRoutine,(void*)"thread1");//void **retval是一个输出型参数void* ret=nullptr;pthread_join(tid,&ret);cout<<"main thread join success,*ret: "<<(long long)ret<

在这里插入图片描述
2. 调用pthread_exit()
在这里插入图片描述

void* startRoutine(void* args)
{const char* name=static_cast(args);int cnt=5;while(true){printTid(name,pthread_self());sleep(1);if(!(cnt--)){break;}}cout<<"线程退出啦..."<pthread_t tid;int n=pthread_create(&tid,nullptr,startRoutine,(void*)"thread1");//void **retval是一个输出型参数void* ret=nullptr;pthread_join(tid,&ret);cout<<"main thread join success,*ret: "<<(long long)ret<

在这里插入图片描述
3. 调用pthread_cancel()
在这里插入图片描述

void* startRoutine(void* args)
{const char* name=static_cast(args);while(true){printTid(name,pthread_self());sleep(1);}cout<<"线程退出啦..."<pthread_t tid;int n=pthread_create(&tid,nullptr,startRoutine,(void*)"thread1");sleep(3);//代表main线程对应的工作pthread_cancel(tid);cout<<"new thread been canceled"<

在这里插入图片描述
给线程发送取消请求,如果线程是被取消的,退出结果是:-1。

线程的局部存储

int global_value=100;
void* startRoutine(void* args)
{while(true){cout<<"thread "<pthread_t t1;pthread_t t2;pthread_t t3;pthread_create(&t1,nullptr,startRoutine,(void*)"thread 1");pthread_create(&t1,nullptr,startRoutine,(void*)"thread 2");pthread_create(&t1,nullptr,startRoutine,(void*)"thread 3");pthread_join(t1,nullptr);pthread_join(t2,nullptr);pthread_join(t3,nullptr);return 0;
}

在这里插入图片描述
每个线程访问的是同一个变量!

如何让每个线程具有只属于自己的全局变量呢?

__thread int global_value=100;

在这里插入图片描述
每个线程就有了属于自己的变量了!—>局部存储!

获取线程的tid
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

线程的分离

在这里插入图片描述
关于pthread_join的返回值

在这里插入图片描述

int global_value = 100;
void *startRoutine(void *args)
{pthread_detach(pthread_self());std::cout<<"线程分离..."<cout << "thread " << pthread_self()<< " global_value: " << global_value<< " &global_value: " << &global_value<< " LWP: " << syscall(SYS_gettid)<< endl;sleep(1);}
}int main()
{pthread_t tid1;pthread_t tid2;pthread_t tid3;pthread_create(&tid1, nullptr, startRoutine,  (void *)"thread 1");pthread_create(&tid2, nullptr, startRoutine, (void *)"thread 2");pthread_create(&tid3, nullptr, startRoutine, (void *)"thread 3");sleep(2);int n = pthread_join(tid1, nullptr);cout << n << " : " << strerror(n) << endl;n = pthread_join(tid2, nullptr);cout << n << " : " << strerror(n) << endl;n = pthread_join(tid3, nullptr);cout << n << " : " << strerror(n) << endl;return 0;
}

在这里插入图片描述
所以我们在执行pthread_detach()分离线程以后就不能使用pthread_join()等待线程退出了!!

但是我们倾向于让主线程分离其他线程!

int main()
{pthread_t tid1;pthread_t tid2;pthread_t tid3;pthread_create(&tid1, nullptr, startRoutine,  (void *)"thread 1");pthread_create(&tid2, nullptr, startRoutine, (void *)"thread 2");pthread_create(&tid3, nullptr, startRoutine, (void *)"thread 3");sleep(2);pthread_detach(tid1);pthread_detach(tid2);pthread_detach(tid3);return 0;
}

线程可以立即分离和延后分离----线程活着----意味着我们不在关心这个线程的死活了—可以理解为线程退出的第四种方式!(延后分离)

新线程分离,但是主线程先退出(进程退出!)—一般我们分离线程,对应的main thread一般不要退出(常驻内存的进程)

exit()

pthread_exit()是退出该线程!
任何一个线程调用exit(),都表示整个进程退出!!!

三、线程的互斥

进城线程间的互斥相关背景概念

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

在这里插入图片描述

互斥量mutex

  • 大部分情况,线程使用的数据都是局部变量,变量的地址空间在线程栈空间内,这种情况,变量归属单个线程,其他线程无法获得这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。
  • 多个线程并发的操作共享变量,会带来一个问题。

模拟抢票逻辑

//临界资源
// int 票数计数器
int tickets = 1000; //临界资源,可能会因为共同访问,可能会造成数据不一致问题
void *getTickets(void *args)
{const char *name = static_cast(args);while (true){if (tickets > 0){usleep(10000);cout << name << "抢到了票,票的编号: " << tickets << endl;tickets--;usleep(123);//模拟其他业务逻辑的执行}else{//票抢到几张,就算没有了呢?--->0cout << name << " 已经放弃抢票了,因为没有了..." << endl;break;}}return nullptr;
}int main()
{pthread_t tid1;pthread_t tid2;pthread_t tid3;pthread_t tid4;pthread_create(&tid1, nullptr, getTickets, (void *)"thread 1");pthread_create(&tid2, nullptr, getTickets, (void *)"thread 2");pthread_create(&tid3, nullptr, getTickets, (void *)"thread 3");pthread_create(&tid4, nullptr, getTickets, (void *)"thread 4");int n = pthread_join(tid1, nullptr);cout << n << " : " << strerror(n) << endl;n = pthread_join(tid2, nullptr);cout << n << " : " << strerror(n) << endl;n = pthread_join(tid3, nullptr);cout << n << " : " << strerror(n) << endl;n = pthread_join(tid4, nullptr);cout << n << " : " << strerror(n) << endl;return 0;
}

正常情况下,票抢到0张的时候是不是就结束了呢?请看以下结果

在这里插入图片描述
这种情况存在偶然性,但是真正抢票的时候是不能发生的!!!

因为tickets–:是由一条语句完成的吗???—>并不是,在底层汇编语句的实现,要至少被翻译称为三条语句!!!

在这里插入图片描述
tickets–:

  1. load tickets to reg;
  2. reg–;
  3. write reg to tickets;

在上面三条语句的任何地方,线程都有可能被切换走!!!

CPU的寄存器是被所有的执行流共享的,但是寄存器里面的数据是属于当前执行流的上下文数据的!(线程被切换的时候,需要保存上下文;线程被换回的时候,需要恢复上下文)

**为了避免以上情况的发生,我们应该保证访问临界区的操作是原子的!**所以访问临界区的操作我们可以给他加锁!

解决问题

在这里插入图片描述
在这里插入图片描述

//临界资源
// int 票数计数器
int tickets = 1000; //临界资源,可能会因为共同访问,可能会造成数据不一致问题
pthread_mutex_t mutex;
void *getTickets(void *args)
{const char *name = static_cast(args);while (true){//临界区,只要对临界区加锁,而且加锁的粒度越细越好//加锁的本质是让线程执行临界区代码串行化//加锁是一套规范法,通过临界区对临界资源进行访问的时候,要加就都要加pthread_mutex_lock(&mutex);if (tickets > 0){usleep(10000);cout << name << "抢到了票,票的编号: " << tickets << endl;tickets--;pthread_mutex_unlock(&mutex);usleep(123);//模拟其他业务逻辑的执行}else{//票抢到几张,就算没有了呢?--->0cout << name << " 已经放弃抢票了,因为没有了..." << endl;pthread_mutex_unlock(&mutex);break;}}return nullptr;
}int main()
{pthread_mutex_init(&mutex, nullptr);pthread_t tid1;pthread_t tid2;pthread_t tid3;pthread_t tid4;pthread_create(&tid1, nullptr, getTickets, (void *)"thread 1");pthread_create(&tid2, nullptr, getTickets, (void *)"thread 2");pthread_create(&tid3, nullptr, getTickets, (void *)"thread 3");pthread_create(&tid4, nullptr, getTickets, (void *)"thread 4");int n = pthread_join(tid1, nullptr);cout << n << " : " << strerror(n) << endl;n = pthread_join(tid2, nullptr);cout << n << " : " << strerror(n) << endl;n = pthread_join(tid3, nullptr);cout << n << " : " << strerror(n) << endl;n = pthread_join(tid4, nullptr);cout << n << " : " << strerror(n) << endl;pthread_mutex_destroy(&mutex);return 0;
}

在这里插入图片描述

锁保护的是临界区,换言之任何线程执行我们临界区代码,访问临界资源。都必须先申请锁,前提是都必须看到锁
这把锁,本身不就也是临界资源吗??锁的设计者早就想到了
pthread_mutex_lock:竞争和申请锁的过程,就是原子的!!

如果锁是main创建的并且初始化的,线程想要获得锁和线程名可以使用结构体传参!
结构体传参

int tickets = 1000;
#define NAMESIZE 100
typedef struct threadData
{char _name[NAMESIZE];pthread_mutex_t *_mutex;
} threadData;void *startRoutine(void *args)
{threadData *td = static_cast(args);const char *name = td->_name;pthread_mutex_t *mutex = td->_mutex;while (true){pthread_mutex_lock(mutex); //如果申请不到,就阻塞进程if (tickets > 0){usleep(1000);cout << name << " get a ticket: " << tickets << endl;tickets--;pthread_mutex_unlock(mutex);//你还有其他的事情做usleep(500);}else{pthread_mutex_unlock(mutex);break;}}
}
int main()
{static pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;pthread_t t1;pthread_t t2;pthread_t t3;pthread_t t4;threadData* td=new threadData();strcpy(td->_name,"thread 1");td->_mutex=&mutex;pthread_create(&t1, nullptr, startRoutine, (void *)td);pthread_create(&t2, nullptr, startRoutine, (void *)&mutex);pthread_create(&t3, nullptr, startRoutine, (void *)&mutex);pthread_create(&t4, nullptr, startRoutine, (void *)&mutex);pthread_join(t1, nullptr);pthread_join(t2, nullptr);pthread_join(t3, nullptr);pthread_join(t4, nullptr);delete(td);return 0;
}

在这里插入图片描述
也可以正常的实现抢票的逻辑!!!

难道在加锁的临界区里面,就没有线程切换了吗?是不是加锁==不会被切换?

当然是完全可以!因为线程执行的加锁解锁等对应的也是代码,线程在任意代码处都可以被切换的,但是线程加锁是原子的—>这个锁,要么你拿到了,要么没有拿到

在我被切走的时候,绝对不会有线程进入临界区!!!!---->因为线程进入临界区需要先申请锁,但是锁现在被我抱着跑了(即便我没有被调度)—>新线程申请不到,只能被挂起了!!!

所以一旦一个线程持有了锁,该线程根本就不担心任何切换问题!!!
对于其他线程而言,该线程访问临界区,只有没有进入和访问完毕两种状态!!!这样才对其他线程有意义!!!

所以这就要求我们尽量不要在临界区里面做一些耗时的事情!!

互斥量实现原理探究

  • 经过上面抢票的例子,大家肯定能意识到单纯的tickets++不是原子的,有可能会出现数据不一致问题
  • 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange指令,该指令的作业是把寄存器和内存单元的数据相交换,由于只有一条指令,保证原子性,即使是多处理平台,访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。我们来做一个lock的伪代码帮助大家理解:

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
线程b在线程a执行swap或者exchange交换前切入线程后就可以获得锁,之后线程a在切换也只是吧线程中mutex的0在来回切换,执行if判断也就只能挂起等待了!

本质:将数据从内存读入存储器,本质是将数据从共享变成线程私有!
1就如同令牌一般

解锁的过程就是把1在写入内存中的变量mutex即可!

基于RAII机制锁的模拟实现

log.hpp

/** @Author: hulu 2367598978@qq.com* @Date: 2022-11-17 16:46:55* @LastEditors: hulu 2367598978@qq.com* @LastEditTime: 2022-11-17 16:49:18* @FilePath: /2022_11_16/lock.hpp* @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE*/
#pragma once#include 
#include class Mutex
{
public:Mutex(){pthread_mutex_init(&_lock, nullptr);}void lock(){pthread_mutex_lock(&_lock);}void unlock(){pthread_mutex_unlock(&_lock);}~Mutex(){pthread_mutex_destroy(&_lock);}private:pthread_mutex_t _lock;
};class LockGuard
{
public:LockGuard(Mutex* mutex): _mutex(mutex){_mutex->lock();std::cout<<"加锁成功..."<_mutex->unlock();std::cout<<"解锁成功..."<

main.cc

Mutex mutex;
int tickets = 1000;
//函数本质就是一个代码块,
bool getTickets()
{bool ret=false;//函数的局部变量,在栈上保存,线程具有独立的栈结构,每个线程各自一份LockGuard lock_guard(&mutex);//局部对象的声明生命周期是随代码块的if (tickets > 0){usleep(1000);cout << "thread " <const char* name=static_cast(args);while(true){if(!getTickets()){break;}cout<pthread_t t1;pthread_t t2;pthread_t t3;pthread_t t4;pthread_create(&t1, nullptr, startRoutine, (void *)"thread 1");pthread_create(&t2, nullptr, startRoutine, (void *)"thread 2");pthread_create(&t3, nullptr, startRoutine, (void *)"thread 3");pthread_create(&t4, nullptr, startRoutine, (void *)"thread 4");pthread_join(t1, nullptr);pthread_join(t2, nullptr);pthread_join(t3, nullptr);pthread_join(t4, nullptr);return 0;
}

在这里插入图片描述

四、可重入VS线程安全

概念

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

常见的线程不安全的情况

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

常见的线程安全的问题

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

常见不可重入的情况

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

常见可重入的情况

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

可重入与线程安全联系

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

可重入与线程安全区别

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

五、常见锁概念

死锁

死锁是指在一组进程中的各个进程均占有不会释放的资源,但因互相申请被其他进程所占用不会释放的资源而处于一种永久等待的状态

代码重现死锁问题

#include 
#include 
#include 
#include 
#include"lock.hpp"
using namespace std;pthread_mutex_t mutexA=PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t mutexB=PTHREAD_MUTEX_INITIALIZER;void* startRoutine1(void* args)
{while(true){pthread_mutex_lock(&mutexA);sleep(1);pthread_mutex_lock(&mutexB);cout<<"我是线程1,我的tid: "<while(true){pthread_mutex_lock(&mutexB);sleep(1);pthread_mutex_lock(&mutexA);cout<<"我是线程2,我的tid: "<pthread_t t1,t2;pthread_create(&t1,nullptr,startRoutine1,nullptr);pthread_create(&t1,nullptr,startRoutine2,nullptr);pthread_join(t1,nullptr);pthread_join(t2,nullptr);return 0;
}

在这里插入图片描述
上面打的东西乱是缺少访问控制。

在这里插入图片描述
此时线程还未退出,但是卡在这里不执行了。这就是死锁问题!!!

死锁的四个必要条件

  • 互斥条件:一个资源每次只能被一个执行流使用
  • 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放
  • 不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺
  • 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系

避免死锁

  • 破坏死锁的四个必要条件
  • 加锁顺序一致
  • 避免锁未释放的场景
  • 资源一次性分配

避免死锁的算法

  • 死锁检测算法(了解)
  • 银行家算法(了解)

总结

(本章完!)

相关内容

热门资讯

AWSECS:访问外部网络时出... 如果您在AWS ECS中部署了应用程序,并且该应用程序需要访问外部网络,但是无法正常访问,可能是因为...
AWSElasticBeans... 在Dockerfile中手动配置nginx反向代理。例如,在Dockerfile中添加以下代码:FR...
银河麒麟V10SP1高级服务器... 银河麒麟高级服务器操作系统简介: 银河麒麟高级服务器操作系统V10是针对企业级关键业务...
北信源内网安全管理卸载 北信源内网安全管理是一款网络安全管理软件,主要用于保护内网安全。在日常使用过程中,卸载该软件是一种常...
AWR报告解读 WORKLOAD REPOSITORY PDB report (PDB snapshots) AW...
AWS管理控制台菜单和权限 要在AWS管理控制台中创建菜单和权限,您可以使用AWS Identity and Access Ma...
​ToDesk 远程工具安装及... 目录 前言 ToDesk 优势 ToDesk 下载安装 ToDesk 功能展示 文件传输 设备链接 ...
群晖外网访问终极解决方法:IP... 写在前面的话 受够了群晖的quickconnet的小水管了,急需一个新的解决方法&#x...
不能访问光猫的的管理页面 光猫是现代家庭宽带网络的重要组成部分,它可以提供高速稳定的网络连接。但是,有时候我们会遇到不能访问光...
Azure构建流程(Power... 这可能是由于配置错误导致的问题。请检查构建流程任务中的“发布构建制品”步骤,确保正确配置了“Arti...