当前位置: 首页 > ops >正文

【Linux系统】万字解析,进程间的信号

前言:

        上文我们讲到了,进程间通信的命名管道与共享内存:【Linux系统】命名管道与共享内存-CSDN博客​​​​​​

        本文我们来讲一讲,进程的信号问题

        点个关注!


信号概念

        信号是OS发送给进程的异步机制!所谓异步指的是,发送信息这个动作,并不会干扰进程本身!

对于信号的基本认识:

        1.什么样的信号应该如何处理,是在信号产生之前早就得知了的
        2.信号的处理并不是立即处理,而是等待合适的时间去处理
        3.对于进程来说,其内部是以及内置了对于信号的识别以及处理方式
        4.产生信号的方式很多,也就是说信号源非常多

信号的产生

信号的产生有很多方式

1.键盘产生信号

        之前我们常见的:Ctrl + c就是信号,用于终止进程!

        信号都有那些:

        其中,我们只需要关注信号1~31(普通信号),信号的名字本身是,其真正的值就是前面的编号。

处理信号

        进程收到信号之后,进程会在合适的时候,进程处理!其中处理的方式有三种

1.执行默认的处理动作!(相当一部分的信号默认动作都是终止进程)
2.执行自定义动作!
3.忽略信号,继续做自己的事!

自定义处理
 #include <signal.h>typedef void (*sighandler_t)(int);sighandler_t signal(int signum, sighandler_t handler);
#include <signal.h>
#include <iostream>
using namespace std;void sighandler(int i)
{cout << "收到一个信号:" << i << endl;
}int main()
{// 将1~31的信号全部自定义for (int i = 0; i < 32; i++){signal(i, sighandler);}while (1){}
}
hyc@hyc-alicloud:~/linux/进程信号$ ./test
^C收到一个信号:2
^C收到一个信号:2
^C收到一个信号:2
^C收到一个信号:2
^C收到一个信号:2

        可以看到,我们发送了多次的Ctrl + c信号,可见Ctrl + c信号发送的就是2号信号:SIGINT

        当然并不是说有的信号都可以被自定义,那不然进程就无法停止了!

前后台

        当我们运行可执行程序时,我们发现Linux指令不起作用了?!这就是前后台的问题了

在OS中,进程分为:前台进程、后台进程
前台进程:有且仅有一个!并且只有前台进程才能接收输入的数据!
后台进程:可以有多个!
虽然输入的数据只有前台进程可以接收,但是输出的数据可以由前后台共同进行的!

        所以,当我们运行我们的程序时,当前这个程序就处于前台了!那么负责接收解析指令的shell程序就会退出前台!而后台程序是不能接收输入进来的数据的,所以这才导致我们输入的指令没有反应!

        

发送信号的本质

        信号发送给进程后,进程需要在合适的时间再进行处理!那么这就意味着进程需要先将信号保存下来!后续再读取执行。

        那么保存在哪里呢?答案是保存在task_struct的sigs变量中!其中sigs采用的是位图结构比特位的位置表示信号的编号比特位的内容(1表示收到、0表示没有收到)表示是否收到

        所以,发送信号的本质就是,向目标进程写信号 -> 修改位图!

        但是task_struct中的数据属于OS内核数据!所以想要修改其数据,就只能让OS自己来修改!所以信号只能让OS来发送!

2.系统调用产生信号

kill接口
#include <signal.h>
int kill(pid_t pid, int sig);作用:向指定的进程发送信号!pid 参数:
pid > 0:向指定进程 ID 的进程发送信号
pid = 0:向与调用进程同进程组的所有进程发送信号sig 参数:
代表信号编号

看看效果:

#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
#include <iostream>
using namespace std;void sighandler(int i)
{cout << "收到一个信号:" << i << endl;
}int main()
{// 将1~31的信号全部自定义for (int i = 0; i < 32; i++){signal(i, sighandler);}kill(getpid(), 2);
}

也可以通过kill来验证一下,上面说的“并不是所有信号都可以被自定义!”

#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
#include <iostream>
using namespace std;void sighandler(int i)
{cout << "收到一个信号:" << i << endl;
}int main()
{// 将1~31的信号全部自定义for (int i = 0; i < 32; i++){signal(i, sighandler);}for (int i = 1; i < 32; i++){kill(getpid(), i);}
}

        可见,信号9并不能被“自定义”!当然不仅仅编号9,还有其他信号也不能被自定义。

abort接口
#include <stdlib.h>
void abort(void);作用:强制终止当前的进程!

看看效果:

#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <iostream>
using namespace std;void sighandler(int i)
{cout << "收到一个信号:" << i << endl;
}int main()
{// 将1~31的信号全部自定义for (int i = 0; i < 32; i++){signal(i, sighandler);}abort();for (int i = 1; i < 32; i++){kill(getpid(), i);}
}

        显然,进程并没有执行for循环!这说明:即使自定义了处理函数,abort 最终仍会强制终止进程!

alarm接口
#include <unistd.h>
unsigned int alarm(unsigned int seconds);作用:向当前进程发送SIGLRM信号参数 seconds:指定定时器的超时时间(单位:秒)
若 seconds > 0:内核会在 seconds 秒后向当前进程发送 SIGALRM 信号
若 seconds = 0:取消当前进程中已设置的所有 alarm 定时器(如果存在)返回值:
若之前已设置过 alarm 定时器且未超时:返回剩余的秒数(即距离上次设置的超时时间还剩多久)
若之前未设置过 alarm 或已超时:返回 0

看看效果:

void sighandler(int i)
{cout << "收到一个信号:" << i << endl;
}int main()
{// 将1~31的信号全部自定义for (int i = 0; i < 32; i++){signal(i, sighandler);}alarm(5);sleep(5);
}

        可见,确实发送了信号!

3.命令产生信号

killall -9 chrome  # 发送SIGKILL(9),强制终止所有chrome进程

        很简单就不过多说明了

4.异常产生信号

程序异常:出现“除0错误”“野指针” 等错误!

#include <signal.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <iostream>
using namespace std;void sighandler(int i)
{cout << "收到一个信号:" << i << endl;
}int main()
{// 将1~31的信号全部自定义for (int i = 0; i < 32; i++){signal(i, sighandler);}int a = 1;a /= 0;
}

        除零错误,发送信号8!

void sighandler(int i)
{cout << "收到一个信号:" << i << endl;
}int main()
{// 将1~31的信号全部自定义for (int i = 0; i < 32; i++){signal(i, sighandler);}int *p = nullptr;*p = 10;
}

        同样的,野指针也发送了信号!

        上面我们说到所有的信号都是由OS来进行发送的!那OS是如何得知程序出现错误的呢?

        因为,OS是所有软硬资源的管理者!通过硬件协作自身监控机制,实时捕获程序运行中的异常状态

信号的保存

信号的产生我们知道了,下面我们来看信号是如何保存的

核心概念

        信号从产生到递达之间的状态,称作信号未决(Pending)

        进程可以选择阻塞(Block)某个信号

        被阻塞的信号会处于未决状态,直到进程解除对该信号的阻塞才会执行递达动作

        信号递达后分别有3个动作:默认动作、自定义动作、忽略!

        注:忽略是递达后的动作!而阻塞是未递达的动作!

保存

        task_sturct中存在三张表,信号由三张表负责保存。

        handler表,保存信号的处理方法,其本质的函数指针数组。SIG_DFL表示默认方法,SIG_IGN表示忽略,使用接口sighandler(int sigon)表示自定义方法!(SIG_DFL本质是宏,其内容是被强转的整数0:(_sighandler_t) 0。)

        pending表,保存信号是否被接收,其本质是位图。0表示没有接收到,1表示接收到了。

        block表,保存信号是否被阻塞,其本质是位图。0表示没有被阻塞,1表示被阻塞了。

        一行信息才是一个信号的完整信息!从上往下,依次表示信号1~31!

sigset_t

        sigset_t是一个数据类型,表示信号集!用于记录每个信号的“有效”“无效”状态。

        从上图来看,我们发现每个信号的block、pending都只使用一个bit位来表示!而并不记录这个信号产生了多少次!所以这两个信号都用sigset_t来存储,分别叫做未决信号集阻塞信号集(屏蔽信号集)

信号集操作函数

sigprocmask

#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oset); 返回值:若成功则为0,若出错则为-1 

参数:

      sigprocmask的行为由how决定,set和oldset分别用于指定新的信号集和保存旧的信号集

how控制信号屏蔽字的修改方式,仅支持 3 个预定义值(核心参数)。
set(输入型参数)指向新的信号集:
  若 how 非 0,此参数指定要操作的信号集;
  若为 NULL,表示不修改屏蔽字(仅用于获取旧屏蔽字)。
oldset(输出型参数)用于保存修改前的旧信号屏蔽字:
  若为 NULL,表示不保存旧屏蔽字。

how的取值:

sigpending

#include <signal.h>
int sigpending(sigset_t *set);作用:获取当前进程未决信号集。参数:set是指向sigset_t类型的指针,用于存储未决信号集合。
返回值:成功时返回0;失败时返回-1,并设置errno。可能的错误包括EFAULT(set指向非法地址)。

演示:

#include <signal.h>
#include <iostream>
using namespace std;void Print(sigset_t pending)
{cout << "当前进程:" << getpid() << "pending:" << endl;for (int i = 31; i >= 1; i--){// 检查信号编号i是否在pending中if (sigismember(&pending, i)){cout << 1;}else{cout << 0;}}cout << endl;
}void handler(int signo)
{cout << "信号递达!" << endl;sigset_t pending;sigpending(&pending);Print(pending);
}int main()
{// 捕捉2号信号signal(2, handler);// 屏蔽2号信号sigset_t set, oldset;sigemptyset(&set);sigemptyset(&oldset);    // 将信号集初始化为空!sigaddset(&set, SIGINT); // 向指定信号集,添加信号// 向进程阻塞信号集添加信息,让SIGINT信号被阻塞sigprocmask(SIG_BLOCK, &set, &oldset);int cnt = 10;while (true){// 获取当前进程的pending信号集sigset_t pending;sigpending(&pending);// 打印pending信号集Print(pending);cnt--;// 解除对2号信号的阻塞if (cnt == 0){cout << "解除对2号信号的阻塞\n";sigprocmask(SIG_SETMASK, &oldset, &set);}sleep(1);}
}

        我们可以看到:在还没有解除2号信号阻塞时,信号确实接收到了!但没有递达。当信号阻塞解除时,信号立马递达了

注意:

        当信号准备抵达时,会先将pending表中信号对应的1修改为0!避免同一个信号被反复递达。

        

补充:

      在Linux中信号中止的方式有两种:CoreTerm

      其唯一区别就在于是否会“核心转储”!  Core:在进程异常退出时,会在当前路径下形成一个文件,将进程的核心数据拷贝至文件中,然后将进程退出!

        而Term则会直接进行进程退出!核心转储的目的是为了实现debug!开启core dump,程序运行崩溃时,gdb core-file core,可以直接帮我们定位到错误的地方!

        但在云服务器上 core dump功能是被禁用掉的,因为云服务器是部署端,不是生产端。

        当然也可以通过 ulimit -a 查看,ulimit -c打开core dump功能。

信号处理

信号的保存我们知道了是如何进行的,下面我们来讲一讲信号的如何处理的

        上面我们讲到了信号的处理,进程收到了信号不是立即处理!而是在合适的时间进行处理。

先直接给出结论:

        适合的时间:进程从内核态,返回至用户态的时候。此时会进行信号检查(检查spending若发现接收到了信号,则再去检查block,若block没有显示阻塞,则去执行信号对应的方法!反之不满足任何一点)

        我们在执行自定义方法时,OS也必须进行用户身份的转化!:用户态与内核态的转化。(因为用户身份是无法访问操作系统的内核数据的)。当然仅执行默认动作(完全由内核态完成,这是系统预定义好的)或忽略动作是不需要的用户身份转化的!

举例说明理解上图:

        用户创建信号SIGQUIT的自定义执行函数sighandler。

        当前正在执行main函数,发生了中断、异常或系统调用,切换至内核态处理中断、异常或系统调用。

        当在内核态处理完中断、异常或系统调用后,返回main函数之前。要对信号进行检查!当前检查到信号SIGQUIT需要递达

        然后内核决定返回用户态去执行sighandler函数,而不是返回到用户态的main函数。sighandler函数与main函数使用不同的堆栈空间,它们之间互不影响!

        sighandler函数返回后,自动执行特殊的系统调用:sigreturn,再次进入内核态

        此时没有新的信号需要递达,那么会再次返回用户态,并且是返回的main函数。恢复main函数的上下文继续执行。

处理流程总结如下图:

        一次流程会进行4次身份切换。

值得一提:

        我们自己的程序会进入内核吗?当然会!因为自己写的程序执行后也是一个进程,而只要是进程就会被OS调度!所以自己写的程序会进行内核!

sigaction

补充一个接口:自定义信号处理方式

        上面我们讲到了接口:signal。sigaction的功能与signal类似,同样可以进行对信号进行自定义处理。不过signal的功能更多。

#include <signal.h>int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);signum:目标信号
act:结构体,自定义捕捉方法(输入型参数)
oldact:结构体,保存之前的方法(输出型参数)
返回值:成功返回 0,失败返回 -1 并设置 errno结构体:
struct sigaction {void     (*sa_handler)(int);                        //自定义捕捉方法void     (*sa_sigaction)(int, siginfo_t*, void*);   //暂不考虑sigset_t   sa_mask;                                 //信号集int        sa_flags;                                //暂不考虑void     (*sa_restorer)(void);                      //暂不考虑
};

具体功能介绍:

        1.当某个信号的处理函数被调用时,内核自动将当前信号加入进程的信号屏蔽字,当信号处理函数返回时自动恢复原来的信号屏蔽字,这样就保证了在处理某个信号时,如果这种信号再次产生,那么它会被阻塞到当前处理结束为止。

        2.如果想不仅仅让当前的信号被屏蔽,可以在sa_mask中添加想要额外屏蔽的信号

演示:

#include <signal.h>
#include <iostream>
using namespace std;void Print()
{sigset_t pending;sigpending(&pending); // 获取当前信号的pending表for (int i = 31; i >= 1; i--){if (sigismember(&pending, i))cout << 1;elsecout << 0;}cout << endl;sleep(1);
}void handler(int sigon)
{cout << "获取到信号:2" << endl;while (true){Print();}
}int main()
{struct sigaction act, oldact;act.sa_handler = handler;sigaction(2, &act, &oldact);while (true){Print();}
}

        运行结果如上图。

        我们可以看到当我们第一次发送2号信号时,获取到信号立刻递达!pending表为空。但当我们第二次发送pending表时,可以看到没有显示获取到信号的信息,pending出现内容表示未决。

        验证了上面所说的特性:sigaction函数会自动的屏蔽当前获取到的信号,知道这个信号处理完,才会解除屏蔽。当有相同的信号发送时,会自动的阻塞。

也可以通过向sa_mask添加想要屏蔽的信号:

例如:捕捉2号信号、屏蔽3,4号信号

#include <signal.h>
#include <iostream>
using namespace std;void Print()
{sigset_t pending;sigpending(&pending); // 获取当前信号的pending表cout << getpid() << ":";for (int i = 31; i >= 1; i--){if (sigismember(&pending, i))cout << 1;elsecout << 0;}cout << endl;sleep(5);
}void handler(int sigon)
{cout << "获取到信号:" << sigon << endl;while (true){Print();}
}int main()
{struct sigaction act, oldact;act.sa_handler = handler;// 向sa_mask添加想要屏蔽的信号sigaddset(&act.sa_mask, 3);sigaddset(&act.sa_mask, 4);sigaction(2, &act, &oldact);while (true){Print();}
}

穿插了解-OS是如何运行的

硬件中断

理解操作系统必不可少一个概念

        由外设触发的,中断系统运行流程,叫做硬件中断。

        作用:当外设准备好的时候,会直接中断控制器发送中断,中断控制器会通知CPU:已经有外设准备好了!CPU得知后会向中断控制器获取准备好的外设的中断号,随后在中断向量表中,根据中断号,找到对应的中断服务,CPU执行对应服务。

        所以CPU并不关心外设是否准备好,而是外设会主动的告诉CPU!

        中断向量表:是操作系统的一部分,在操作系统启动时就加载到内存中了。其本质可以理解为:函数指针数组,中断号就是其下标!

        补充:对于寄存器的概念,其实不仅仅CPU有,外设也有寄存器!

了解完中断,不知你有莫名的熟悉感吗?

        发送中断-----发送信号?

        获取中断号------保存信号?

        中断号------信号编号

        处理中断-----处理信号?自定义捕捉?

        是的!信号是纯软件,其本质是模拟硬件的中断!

时钟中断

现在我们知道了当有硬件中断时,会根据中断号,去执行对应的中断服务!

        而当没有没有中断时,OS此时在干什么呢?OS是暂停的!

        是的!你没有看错,没有中断的OS是暂停的!也就是说OS是依靠中断来驱动的!

        OS是不可能暂停不运行的!

        于是有了一个时钟源,时钟源会以一个固定的频率不断的向OS发送特殊中断!获取到这个中断号,并执行进程调度任务!

        于是OS就在时钟的驱动下运行起来了!

        时钟频率:既是我们常说的主频!根据时钟的频率+时间戳,就可以让我们的计算机在离线状态下也可以知晓正确的时间!

        同样的,一个进程的时间片是否耗尽也是通过时钟频率判断的!每进行一次进程调度,就会让目前正在被运行的进程的时间片 --,减去后再次判断时间片是否耗尽,若耗尽OS将会剥离进程,没有耗尽则进行执行!

死循环

        OS的本质是一个死循环!

        OS本身是躺平的!OS本身并不干什么事情。需要做什么事情,就向中断向量表钟添加对应的方法即可!

软中断

软件也能触发中断吗?当然可以!

通过软件的错误逻辑让硬件发生异常进行中断:

        如:除0错误、野指针、等等。

        例如除0错误,引发EFLAGS寄存器硬件,发生CPU内部的中断!

不通过硬件,仅仅通过软件进行中断:

        隆重介绍两个系统调用:

        int 0x80

        syscall

     (这是通过指令实现的系统调用,其本质是指令集!其实C/C++代码编译后:就是指令集+数据,考虑汇编就很好理解)

        调用这两个系统调用,即可让CPU自动进行软中断。

        中断后进行中断服务。而软中断的中断服务是去系统调用函数指针表中查询并执行对应的系统调用方法

        其中我们查询调用方法是依靠数组下标去查询的,而这个数组下标我们叫做:系统调用号

        其中系统调用号由OS提供!

        请注意!之前我们讲到过系统调用是由OS提供的。其实OS只提供系统调用号,不提供任何接口!!!我们所使用的系统调用接口其实是由glibc封装系统调用号实现的。系统调用号存放在头文件<unistd_64.h>中,通过寄存器exa传递给OS

        所以系统调用的简单流程就是:先通过int 0X80或syscall触发软中断,陷入内核、然后执行中断服务、进入系统调用函数指针数组、根据系统调用号查找并执行对应的系统调用方法。

用户态与内核态

先给出结论:

        用户态:就是在虚拟内存中,执行用户区[0-3]GB时的状态。        

        内核态:就是在虚拟内存中,执行内核区[3-4]GB时的状态。        

关于页表:

        对于用户页表,每一个进程都有一个独立的用户页表!因为进程具有独立性,每一个进程都有其独有的数据与代码。

        对于内核页表,仅有一个内核页表!因为OS的固定的。

        于是,不论是那一个进程执行系统调用,都可以陷入内核区,访问内核的方法与数据!

CPL标志位:

       我们知道用户区与内核区都在虚拟地址空间中,访问用户区还是内核区都是通过虚拟地址来访问的!

        那如果在用户区中,通过内核区的代码直接访问内核岂不是出错了??因为我们说过OS是不能被任何人直接访问的!只能通过系统调用间接访问!

        于是便有了CPL(Current Privilege Level:当前权限级别CPL在CPU中Cs寄存器的低两位

        CPL:3表示当前处于用户态,0表示当前处于内核态。系统会自动维护CPL。

        所以OS会根据CPL标志位,来判断当前操作是否合法,以此来避免不必要的错误。

可重入函数

简单理解:

        重入:就是同时被多个执行流使用、调用。

        可重入函数:可以同时被多个执行流调用,且不会影响函数执行结果的函数

        反之,会影响的就叫做不可重入函数。

        符合一下条件之一,便是不可重入函数:

1.调用了mallco或free,因为mallco是用全局链表管管理的。
2.调用了标准IO库,因为大部分的标准IO库都是不可重入的,其使用了全局变量。

        

volatile

这是一个修饰符

先直接来看一段代码:

#include <signal.h>
#include <stdio.h>
#include <iostream>
using namespace std;int n = 0;
void handler(int sigon)
{cout << "make 0 -> 1" << endl;n = 1;
}int main()
{signal(2, handler);while (n == 0){cout << "循环中" << endl;sleep(2);}cout << "解除循环!" << endl;
}hyc@hyc-alicloud:~/linux/volatile$ ./test
循环中
循环中
循环中
循环中
循环中
^Cmake 0 -> 1
循环中
循环中
循环中
循环中

        我们会发现很奇怪的一点:明明n由0变成1了,为什么循环还不停止呢??

        这与编译器的优化有关!在main函数中n这个变量不进行任何修改,仅仅作为判断条件。于是编译器就将n直接保存至寄存器中,没必要每判断一次就从内存中读取一次,效率太低了!

        于是我们信号自定义的执行方法中即使修改了n的值,但也只是内存数据的修改,CPU并不会区读取内存了,而是直接访问寄存器!

        寄存器覆盖进程变量的真实情况,我们叫做:内存不可见!

        于是我们将会用到:volatile,来保证此变量的内存可见性!

#include <signal.h>
#include <stdio.h>
#include <iostream>
using namespace std;//保证内存可见性!
volatile int n = 0;void handler(int sigon)
{cout << "make 0 -> 1" << endl;n = 1;
}int main()
{signal(2, handler);while (n == 0){cout << "循环中" << endl;sleep(2);}cout << "解除循环!" << endl;
}hyc@hyc-alicloud:~/linux/volatile$ ./test
循环中
循环中
循环中
循环中
^Cmake 0 -> 1
解除循环!

SIGCHLD信号

        SIGCHLD信号,其实是子进程在终止时向父进程发送的信号。不过这个给信号的默认执行动作一般都是忽略,起到一个通知的作用

        我们可以通过SIGCHLD信号,自定义处理动作来让父进程回收子进程

演示:

#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <iostream>
using namespace std;void handler(int sigon)
{cout << "收到信号:" << sigon << endl;int re = waitpid(0, NULL, WNOHANG); // 进行非阻塞等待cout << "完成等待" << endl;
}int main()
{pid_t pid = fork();if (pid == 0){// 子进程cout << "子进程退出!\n";exit(1);}// 父进程signal(SIGCHLD, handler);while (true){// 即使有多个子进程也可以全部等待(非阻塞等待)sleep(2);cout << "父进程running\n";}
}

补充:我们等待子进程往往是想得知子进程执行的结果怎么样,但如果我们并不关心,可以让SIGCHLD信号的默认动作设为忽略。这样子进程结束后就会自动清理资源,不会形成僵尸进程!

#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <iostream>
using namespace std;int main()
{pid_t pid = fork();if (pid == 0){// 子进程cout << "子进程退出!\n";exit(1);}// 父进程signal(SIGCHLD, SIG_IGN);while (true){// 即使有多个子进程也可以全部等待(非阻塞等待)sleep(2);cout << "父进程running\n";}
}

注意区别:

        SIGCHLD信号的默认动作是忽略,用于传达消息通知父进程子进程已经终止,会形成僵尸进程,需要父进程进行等待操作,回收子进程!(默认忽略,被动行为)

        将SIGCHLD信号的处理动作自定义为忽略,表示我们不关心子进程的执行情况,子进程终止时就会自动的清理其空间,不会形成僵尸进程!(显示忽略,主动告知)

http://www.xdnf.cn/news/19593.html

相关文章:

  • Photoshop用户必看:让你的PSD像JPG一样可预览
  • 书写腾讯天气遇到的问题
  • 虚拟继承:破解菱形继承之谜
  • 【论文阅读】Deepseek-VL:走向现实世界的视觉语言理解
  • Postman接口测试工具:高效管理测试用例与环境变量,支持断言验证及团队协作同步
  • 软件设计师——软件工程学习笔记
  • 前端架构知识体系:常见压缩算法全解析及原理揭秘(gzip、zip)
  • 麒麟信安受邀出席第三届电子信息测试产业大会,参编四项团标发布,详解麒麟信安操作系统测试全流程
  • Navicat vs DBeaver vs DataGrip:三款主流数据库客户端深度对比与选择
  • 力扣222 代码随想录Day15 第四题
  • 【高并发内存池】三、线程缓存的设计
  • Steam开发者上架游戏完整指南(含具体技术细节)
  • 【最新Pr 2025安装包(Adobe Premiere Pro 2025 中文解锁版)安装包永久免费版下载安装教程】
  • Java-Spring入门指南(一)Spring简介
  • 如何把HTML转化成桌面Electron
  • B树和B+树,聚簇索引和非聚簇索引
  • 网络准入控制,阻断违规外联-企业内网安全的第一道防线
  • 通用的二叉数迭代方法
  • 深入浅出 RabbitMQ-TTL+死信队列+延迟队列
  • 如何使用Kafka处理高吞吐量的实时数据
  • 赵玉平《跟司马懿学管理》读书笔记
  • 智能高效的Go IDE——GoLand v2025.2全新上线
  • 图像编码--监控摄像机QP设置大小?
  • Git 代码提交管理指南
  • 为啥我Nginx证书配的没问题,但客户端却发现证书不匹配?
  • 从零开始搭建体育电竞比分网,手把手教你全流程
  • 京东科技大模型RAG岗三轮面试全复盘:从八股到开放题的通关指南
  • 若想将gpu的代码在昇腾npu上运行,创建docker应该创建怎么样的docker?(待完善)
  • 从模态融合到高效检索:微算法科技 (NASDAQ:MLGO)CSS场景下的图卷积哈希方法全解析
  • 【XR硬件系列】Apple Vision Pro 完全解读:苹果为我们定义了怎样的 “空间计算” 未来?