【Linux/C++多进程篇(一) 】一个变两个?揭秘 C/C++ 程序中神奇的“分身术”

【Linux/C++多进程篇(一) 】一个变两个?揭秘 C/C++ 程序中神奇的“分身术”

⭐️在这个怀疑的年代,我们依然需要信仰。

个人主页:YYYing.

⭐️Linux/C++进阶系列专栏:【从零开始的linux/c++进阶编程】

⭐️其他专栏:【linux基础】【数据结构与算法】【从零开始的计算机网络学习】

系列上期内容:【Linux/C++文件篇(一) 】标准I/O与文件I/O基础 

系列下期内容:【Linux/C++多进程篇(二) 】万字解析linux系统编程之进程间通信 (IPC)


目录

前言:       

多进程理论基础

一、为什么要引入多进程

二、多进程相关概念

三、进程的内存管理

四、进程与程序的区别

五、进程的种类

六、进程PID

七、特殊的进程

八、linux中有关进程的指令

九、进程状态的切换

多进程的实现

 📖 进程的创建:fork

 📖 父子进程号的获取:getpid、getppid

📖 进程退出:exit/_exit

📖 进程资源回收:wait、waitpid

📖 僵尸进程和孤儿进程

深度解析:fork中的黑魔法

结语

---⭐️封面自取⭐️---



前言:       

        你有没有想过,为什么你的程序只能一件事一件事地处理?就像一个忙碌的服务员,面对众多顾客,却只能一次服务一个人,其他人只能排队等待。

        但在现实生活中,我们常常需要同时处理多件事情:

浏览器同时加载多个网页标签服务器同时响应成百上千的客户端请求视频播放器在播放视频的同时,还能下载后续内容

这背后,靠的就是程序的“分身术”——多进程编程。今天,我们从最底层的内存世界出发,揭开 C++ 多进程“分身术”的神秘面纱。

多进程理论基础

一、为什么要引入多进程

想象一下,你是一个人(单进程),正在厨房里做饭(处理业务)。

  • 现状:你切菜、炒菜、洗碗一把抓。
  • 风险:如果你不小心切到了手(程序崩溃/段错误),或者被一道复杂的菜难住死住了(死循环),整个厨房就瘫痪了,客人吃不上饭,甚至房子都可能着火(服务宕机)。

这时候,“分身术”出现了
你喊了一声“变!”,瞬间变出了另一个你(子进程)。

  • 老大(父进程):负责站在门口迎客、指挥调度、监控老二的状态。
  • 老二(子进程):专门负责进厨房炒菜。

核心收益
如果老二在厨房里被油烫伤了(子进程崩溃),老大毫发无损!老大可以立刻再变出一个“老三”继续炒菜,客人甚至感觉不到中间停顿了。

这就是多进程存在的第一原动力隔离与容错


二、多进程相关概念

  • 进程是程序的一次执行过程,有一定的生命周期,包含了创建态、就绪态、执行态、挂起态、死亡态
  • 进程是计算机资源分配的基本单位,系统会给每个进程分配0--4G的虚拟内存,其中0--3G是用户空 间,3--4G是内核空间
        其中多个进程中0--3G的用户空间是相互独立的,但是,3--4G的内核空间是相互共享的

        用户空间细分为:栈区、堆区、静态区
  • 进程的调度机制:时间片轮询上下文切换机制 

       我们可以看到下图中 ,我们的cpu不停的向我们运行队列中的程序分配时间片,让程序轮流来完成任务,这就是我们的时间片轮询上下文切换机制

  • 并发和并行的区别
        并发:针对于单核CPU系统在处理多个任务时,使用相关的调度机制,实现多个任务进行细化时间片轮询时,在宏观上感觉是多个任务同时执行的操作,同一时刻,只有一个任务在被CPU处理

        并行:是针对于多核CPU而言,处理多个任务时,同一时间,每个CPU处理的任务之间是并行的,实现的是真正意义上多个任务同时执行的

三、进程的内存管理

        此处我们就用一图来带大家快速了解我们进程中的内存管理

        我们先从左往右来看,我们的操作系统是会通过物理内存来进行映射产生虚拟内存,而我们的虚拟内存中又分为3g的用户空间1g的内核空间

       
从图中也不难看出,我们的用户空间其实就是我们之前经常所讲的那些内存分配的占用空间,而内核空间其实就是我们操作其他内核代码与数据存储的地方
  • 物理内存:内存条上(硬件上)真正存在的存储空间
  • 虚拟内存:程序运行后,通过内存映射单元,将物理内存映射出4G的虚拟内存,供进程使用

四、进程与程序的区别

  • 进程:是动态的,进程是程序的一次执行过程,是有生命周期的,进程会被分配0--3G的用户空间,进程是在内存上存着的
  • 程序:是静态的,没有所谓的生命周期,程序存储在磁盘设备上的二进制文件

五、进程的种类

进程一共有三种:交互进程、批处理进程、守护进程

1> 交互进程:它是由shell控制,可以直接和用户进行交互的,例如vim文本编辑器



2> 批处理进程:内部维护了一个队列,被放入该队列中的进程,会被统一处理。例如 g++编译器的一步到位的编译



3> 守护进程:脱离了终端而存在,随着系统的启动而运行,随着系统的退出而停止。例如:操作系统的服务进程
特性交互进程批处理进程守护进程
与终端关系必须关联一个终端(控制终端)通常不关联终端(或与终端无关)脱离终端,无控制终端
用户交互实时交互,等待用户输入无交互,按预设脚本执行无直接用户交互,响应系统请求
运行方式前台或后台(但前台居多)后台运行(作业队列)后台运行,常驻内存
生命周期用户会话期间,用户退出则结束作业执行完即结束系统启动到关闭,长期运行
调度优先级通常较高(响应性要求)可低可高,取决于作业调度通常后台优先级,但重要服务可调高
例子bash、vim、topat作业、batch作业、g++编译任务sshd、httpd、cron

六、进程PID

        那有了进程后,我们的进程之间该怎么区分谁是大哥,谁是二弟呢?这时候就靠我们的进程号 PID 了。

        1> PID(Process ID):进程号,进程号是一个大于等于0的整数值,是进程的唯一标识,不可能重复。

        2> PPID(Parent Process ID):父进程号,系统中允许的每个进程,都是拷贝父进程资源得到的

        3> 在linux系统中的 /proc目录下的数字命名的目录其实都是一个进程

        当然,我们的PID肯定是有获取方法的,我们待会就会讲到。


七、特殊的进程

  • 0号进程(idel):他是由linux操作系统启动后运行的第一个进程,也叫空闲进程,当没有其他进程运行时,会运行该进程。他也是1号进程和2号进程的父进程
  • 1号进程(init):他是由0号进程创建出来的,这个进程会完成一些硬件的必要初始化工作,除此之外,还会收养孤儿进程
  • 2号进程(kthreadd):也称调度进程,这个进程也是由0号进程创建出来的,主要完成任务调度问题
  • 孤儿进程:当前进程还正在运行,其父进程已经退出了。

        说明:每个进程退出后,其分配的系统资源应该由其父进程进行回收,否则会造成资源的浪费

  • 僵尸进程:当前进程已经退出了,但是其父进程没有为其回收资源

八、linux中有关进程的指令

  • ps指令:能够查看当前运行的进程相关属性

        ps -ef :能够显示进程之间的关系



UID:用户ID号 ps -ajx:能够显示当前进程的状态

PID:进程号

PPID:父进程号

C:用处不大

STIME:开始运行的时间

TTY:如果是问号表示这个进程不依赖于终端而存在

CDM:名称

        ps -ajx:能够显示当前进程的状态



PGID:进程组ID

SID:会话组ID

STAT:进程的状态

        ps -aux:可以查看当前进程对CPU和内存的占用率



%CPU:CPU占用率

%MEM:内存占用率

  • top指令动态查看进程的相关属性


        此处,表里面的内容其实是会实时变化的,所以叫做动态查看

  • kill指令:发送信号的指令

        kill -信号号 进程号

                可以通过指令:kill -l查看能够发送的信号有哪些

1、从图可知一共可以发射62个信号,而前32个是稳定信号,后面剩余的是不稳定信号



2、常用的信号

SIGHUP:当进程所在的终端被关闭后,终端会给运行在当前终端的每个进程发送该信号,默认结束进程

SIGINT:中断信号,当用户键入ctrl + c时发射出来

SIGQUIT:退出信号,当用户键入ctrl + /是发送,退出进程

SIGKILL:杀死指定的进程

SIGSEGV:当指针出现越界访问时,会发射,表示段错误

SIGPIPE:当管道破裂时会发送该信号

SIGALRM:当定时器超时后,会发送该信号

SIGSTOP:暂停进程,当用户键入ctrl+z时发射

SIGTSTP:也是暂停进程

SIGTSTP、SIGUSR2 :留给用户自定义的信号,没有默认操作

SIGCHLD:当子进程退出后,会向父进程发送该信号



3、有两个特殊信号:SIGKILL和SIGSTOP,这两个信号既不能被捕获,也不能被忽略

  • pidof:查看进程的进程号

        pidof 进程名


九、进程状态的切换

  • 可以通过 man ps 进行查看进程的状态
进程主状态:

D   uninterruptible sleep (usually IO)   不可中断的休眠态,通常是IO操作

R   running or runnable (on run queue)   运行态

S   interruptible sleep (waiting for an event to complete) 可中断的休眠态

T   stopped by job control signal  停止态

t   stopped by debugger during the tracing 调试时的停止态

W   paging (not valid since the 2.6.xx kernel) 已经弃用的状态

X   dead (should never be seen) 死亡态

Z   defunct ("zombie") process, terminated but not reaped by its parent 僵尸态



附加态:

<   high-priority (not nice to other users)     高优先级进程

N   low-priority (nice to other users)         低优先级进程

L   has pages locked into memory (for real-time and custom IO)   锁在内存中的进程

s   is a session leader         会话组组长

l   is multi-threaded (using CLONE_THREAD, like NPTL pthreads do)   包含多线程的进程

+   is in the foreground process group         前台运行的进程
  • 状态切换的实例
  • 进程主要状态的转换(五态图)

多进程的实现

        进程的创建过程,是子进程通过拷贝父进程得到的,新进程的创建直接拷贝父进程的资源,只需改变很少部分的数据即可,保留了父进程的大部分的数据信息(遗传基因),所以这个拷贝过程,系统通过一个函数fork来自动完成

 📖 进程的创建:fork

函数原型pid_t fork(void);
头文件unistd.h
功能通过拷贝父进程得到一个子进程
参数说明

返回值成功在父进程中得到子进程的pid,在子进程中的到0,失败返回-1并置位错误码

        那如果有多个fork呢?大家可以先猜一下,下图程序运行后我们的进程总共有多少个,会是4个吗?如果有人觉得是4个,那不妨想想,我们的程序是不是从第9行开始创建新的进程,那么新进程将会从第10行开始运行。那么这时候,你还会觉得是4个进程吗?此时我们的进程其实就是8个。

        如果不关注返回值的话,有n个fork,会产生2^n个进程


        那么,fork函数的返回值在上面的表格中我们也说过了,现在我们来看看关注返回值的情况

#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <unistd.h> using namespace std; int main(int argc, const char *argv[]){ pid_t pid = -1; //对于父进程而言会得到大于0的数字,对于子进程而言会得到0 pid = fork(); //创建一个子进程,父进程会将返回值赋值给父进程中的pid变量 //子进程会将返回值赋值给子进程中的pid变量 printf("pid = %d\n", pid); //对pid进程判断 if(pid > 0){ //父进程要做执行的代码 printf("我是父进程\n"); }else if(pid == 0){ //子进程要执行的代码 printf("我是子进程\n"); }else{ perror("fork error"); return -1; } while(1); return 0; }

        对于上述的程序,我们的结果如下


        那我们现在就可以进行父子进程的并发执行案例了

#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <unistd.h> using namespace std; int main(int argc, const char *argv[]){ pid_t pid = -1; pid = fork(); //创建一个子进程,父进程会将返回值赋值给父进程中的pid变量 //子进程会将返回值赋值给子进程中的pid变量 //对pid进程判断 if(pid > 0){ while(1){ //父进程要做执行的代码 printf("我是父进程1111\n"); sleep(1); } }else if(pid == 0){ while(1){ //子进程要执行的代码 printf("我是子进程\n"); sleep(1); } }else{ perror("fork error"); return -1; } return 0; }

        值得一提的是,我们可以看看我们执行的结果:


 📖 父子进程号的获取:getpid、getppid

函数原型pid_t getpid(void);pid_t getppid(void)
头文件

sys/types.h

unistd.h

sys/types.h

unistd.h

功能获取当前进程的进程号获取当前进程的父进程pid号
参数说明

返回值当前进程的进程号当前进程的父进程pid

📖 进程退出:exit/_exit

上述两个函数都可以完成进程的退出,区别是在退出进程时,是否刷新标准IO的缓冲区

exit属于库函数,使用该函数退出进程时,会刷新标准IO的缓冲区后退出

_exit属于系统调用(内核提供的函数),使用该函数退出进程时,不会刷新标准IO的缓冲区

函数原型void exit(int status);void _exit(int status);
头文件stdlib.hstdlib.h
功能退出当前进程,并刷新当前进程打开的标准IO的缓冲区退出当前进程,不刷新当前进程打开的标准IO的缓冲区
参数说明进程退出时的状态,会将改制 与 0377进行位与运算后,返回给回收资源的进程进程退出时的状态,会将改制 与 0377进行位与运算后,返回给回收资源的进程
返回值

📖 进程资源回收:wait、waitpid

有两个函数可以完成对进程资源的回收

wait阻塞回收任意一个子进程的资源函数

waitpid可以阻塞,也可以非阻塞完成对指定的进程号进程资源回收

函数原型void exit(int status);void _exit(int status);
头文件

sys/types.h

sys/wait.h

sys/types.h

sys/wait.h

功能阻塞回收子进程的资源可以阻塞也可以非阻塞回收指定进程的资源
参数说明接收子进程退出时的状态,获取子进程退出时的状态与0377进行位与后的结果,如果不愿意 接收,可以填NULL

参数1:进程号

        >0:表示回收指定的进程,进程号位pid(常用)

        =0:表示回收当前进程所在进程组中的任意一个子进程

        =-1:表示回收任意一个子进程(常用)

        <-1:表示回收指定进程组中的任意一个子进程,进程组id为给定的pid的绝对值

参数2:

        接收子进程退出时的状态,获取子进程退出时的状态与0377进行位与后的结果,如果不愿 意接收,可以填NULL

参数3:是否阻塞

        0:表示阻塞等待

        WNOHANG:表示非阻塞

返回值成功返回回收资源的那个进程的pid号,失败返回-1并置位错误码

>0: 返回的是成功回收的子进程pid号

=0:表示本次没有回收到子进程

=-1:出错并置位错误码

#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <unistd.h> #include <sys/wait.h> using namespace std; int main(int argc, const char *argv[]){ pid_t pid = -1; //定义用于存储进程号的变量 //创建进程 pid = fork(); if(pid > 0) { //父进程程序代码 //输出当前进程号、子进程号、父进程号 printf("self pid=%d, child pid = %d, parent pid = %d\n", getpid(), pid, getppid()); sleep(8); //休眠3秒 //wait(NULL); //回收子进程资源,只有回收了子进程资源后,父进程才继续向后执行 waitpid(-1, NULL, WNOHANG); //非阻塞回收子进程资源 printf("子进程资源已经回收\n"); } else if(pid == 0) { //子进程程序代码 //输出当前进程的进程号、父进程进程号 printf("self pid = %d, parent pid = %d\n", getpid(), getppid()); //提出子进程 printf("11111111111111111111111111111111111"); //没有加换行,不会自动刷新 sleep(3); exit(EXIT_SUCCESS); //刷新缓冲区并退出进程 //_exit(EXIT_SUCCESS); //不刷新缓冲区退出进程 } else { perror("fork error"); return -1; } while(1); //防止进程退出 return 0; }

        下图为运行效果

📖 僵尸进程和孤儿进程

  • 孤儿进程:当前进程还正在运行,其父进程已经退出了。

        每个进程退出后,其分配的系统资源应该由其父进程进行回收,否则会造成资源的浪费

        我们不妨来看看,父进程不回收资源会发生什么

#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <sys/wait.h> using namespace std; int main(int argc, const char *argv[]){ pid_t pid = fork(); if(pid > 0){ sleep(5); exit(EXIT_SUCCESS); }else if(pid == 0){ while(1){ printf("我是子进程\n"); sleep(1); } }else{ perror("fork error"); return -1; } return 0; }

        可以看到在 Linux 中,孤儿不会流落街头。它们会被系统的“福利院院长”—— init 进程 (PID 1) 收养。init 进程会定期帮它们收尸,所以孤儿进程通常无害


  • 僵尸进程:当前进程已经退出了,但是其父进程没有为其回收资源
#include<iostream> #include<cstdio> #include<cstring> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> #include <unistd.h> #include <sys/wait.h> using namespace std; int main(int argc, const char *argv[]){ pid_t pid = fork(); if(pid > 0){ while(1){ printf("我是父进程\n"); sleep(1); } }else if(pid == 0){ sleep(5); exit(EXIT_SUCCESS); }else{ perror("fork error"); return -1; } return 0; }

        效果如下:


深度解析:fork中的黑魔法

那么学完应用方面,再给大家提一点点细枝末节。

我相信很多初学者以为 fork() 是把整个程序从头到尾复制了一遍,就像复印机一样。
那你就错了,如果每次分身都完整复制几 GB 的内存,电脑早就卡死了。

操作系统(OS)使用了一个极其聪明的机制:写时复制 (Copy-On-Write, COW)

通俗解释 COW:

  1. 分身瞬间
    • 父进程和子进程共享同一块物理内存。
    • OS 只是把这块内存标记为“只读”,并给父子进程各自发了一本新的“地图”(页表)。
    • 此时,没有发生任何数据拷贝!速度极快,几乎瞬间完成。
  2. 读取数据时
    • 父子进程去读变量 int a = 10;
    • 因为大家读的都是同一块只读内存,完全没问题,继续共享。
  3. 修改数据时关键点!):
    • 假设子进程说:“我要把 a 改成 20”。
    • OS 拦截了这个操作(触发缺页中断)。
    • OS 心想:“你要改?行,那我单独给你复印一份这一页内存,你在你的副本上随便改,别影响你爸。”
    • 只有在这时,真正的内存拷贝才发生

        那么现在再来想这么一个问题:fork之后,父子进程的内存关系吗?如果我在 fork 前定义了一个全局变量,fork 后子进程改了它,父进程会变吗?

        这个问题的核心就在于理解 Copy-On-Write (写时复制) 机制。

        首先,当调用 fork() 时,操作系统并没有立即在物理内存中复制一份父进程的数据。父子进程在初始阶段是共享同一块物理内存页的,只不过这些页被标记为了只读。此时,如果父子进程都只是读取那个全局变量,它们访问的是同一个物理地址,效率极高。

        其次,关键在于写入。当子进程尝试修改这个全局变量时,CPU 会触发一个缺页异常(Page Fault)。操作系统内核捕获到这个异常后,意识到子进程要‘写’了,于是它会真正地为子进程分配一块新的物理内存,将原数据复制过去,然后让子进程在新的内存页上修改。而父进程的页表依然指向原来的物理内存。

        所以我们的结论就出来了:读操作:父子进程共享物理内存,互不影响(因为都没改)。写操作:一旦发生写操作,内存即刻分离。子进程修改变量,绝对不会影响父进程的值,因为它们此刻已经指向了不同的物理地址。

这种机制既保证了进程间的隔离性(数据安全),又极大优化了性能(避免了不必要的拷贝)。这也是为什么在 C++ 后端中,即使创建大量进程,系统开销也是可控的原因。

结语

今天我们揭开了 C++ 多进程“分身术”的面纱:

  • fork() 是咒语。
  • COW 是省钱的秘诀。
  • 返回值 是身份的证明。
  • wait 是负责任的态度。

但这只是开始。两个独立的进程,如果想互相传个话(比如父进程告诉子进程“该下班了”),或者传个大文件,该怎么办?它们不能直接读对方的内存,那就要用到更有趣的 IPC(进程间通信) 技术了:管道、消息队列、共享内存,我们下一篇文章将会带来这些内容的讲解

我是YYYing,后面还有更精彩的内容,希望各位能多多关注支持一下主包。

无限进步,我们下次再见!


---⭐️封面自取⭐️---

Read more

SpringBoot+Vue 线上辅导班系统平台完整项目源码+SQL脚本+接口文档【Java Web毕设】

SpringBoot+Vue 线上辅导班系统平台完整项目源码+SQL脚本+接口文档【Java Web毕设】

摘要 随着互联网技术的快速发展,线上教育已成为现代教育的重要组成部分,尤其是在后疫情时代,线上辅导的需求显著增长。传统的线下辅导模式受限于时间和空间,难以满足学生个性化学习的需求,而线上辅导班系统能够突破这些限制,提供灵活、高效的学习方式。该系统旨在为学生和教师搭建一个便捷的互动平台,支持课程管理、在线学习、作业提交、实时答疑等功能,从而提升学习效率和教学质量。关键词:线上教育、辅导班系统、Java Web、SpringBoot、Vue。 本系统采用前后端分离架构,后端基于SpringBoot框架实现,提供RESTful API接口,前端使用Vue.js框架构建用户界面,确保系统的高效性和可维护性。数据库采用MySQL存储数据,并通过MyBatis-Plus实现数据持久化操作。系统主要功能包括用户管理(学生、教师、管理员角色)、课程管理、在线学习、作业提交与批改、实时聊天等。系统设计注重用户体验,支持响应式布局,适配多种终端设备。通过JWT实现安全的用户认证与授权,保障数据隐私。关键词:SpringBoot、Vue.js、MySQL、

By Ne0inhk
【Actix Web】Rust Web开发实战:Actix Web框架全面指南

【Actix Web】Rust Web开发实战:Actix Web框架全面指南

✨✨ 欢迎大家来到景天科技苑✨✨ 🎈🎈 养成好习惯,先赞后看哦~🎈🎈 🏆 作者简介:景天科技苑 🏆《头衔》:大厂架构师,华为云开发者社区专家博主,阿里云开发者社区专家博主,ZEEKLOG全栈领域优质创作者,掘金优秀博主,51CTO博客专家等。 🏆《博客》:Rust开发,Python全栈,Golang开发,云原生开发,PyQt5和Tkinter桌面开发,小程序开发,人工智能,js逆向,App逆向,网络系统安全,数据分析,Django,fastapi,flask等框架,云原生K8S,linux,shell脚本等实操经验,网站搭建,数据库等分享。 所属的专栏:Rust语言通关之路 景天的主页:景天科技苑 文章目录 * Rust Web开发 * 一、Actix Web框架概述 * 1.1 Actix Web的特点 * 1.2 Actix Web与其他Rust框架比较

By Ne0inhk
Flutter for OpenHarmony:web_socket 纯 Dart 标准 WebSocket 客户端(跨平台兼容性之王) 深度解析与鸿蒙

Flutter for OpenHarmony:web_socket 纯 Dart 标准 WebSocket 客户端(跨平台兼容性之王) 深度解析与鸿蒙

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.ZEEKLOG.net 前言 虽然 dart:io 提供了 WebSocket 类,dart:html 也提供了 WebSocket 类,但这种“分裂”的 API 设计让编写跨平台(同时支持 Mobile/Web/Desktop)的代码变得异常痛苦。你需要使用条件导入 (if (dart.library.io) ...) 来分别处理。 web_socket 库就是为了解决这个问题而诞生的。它提供了一个统一的、平台无关的WebSocket 接口。 无论你的代码运行在 Android、iOS、Web 还是 OpenHarmony 上,它都会自动选择最底层的实现(在鸿蒙上通常是 dart:io)

By Ne0inhk
【踩坑记录】使用 Layui 框架时解决 Unity WebGL 渲染在 Tab 切换时黑屏问题

【踩坑记录】使用 Layui 框架时解决 Unity WebGL 渲染在 Tab 切换时黑屏问题

【踩坑记录】使用 Layui 框架时解决 Unity WebGL 渲染在 Tab 切换时黑屏问题 在开发 Web 应用时,尤其是集成了 Unity WebGL 内容的页面,遇到一个问题:当 Unity WebGL 渲染内容嵌入到一个 Tab 中时,切换 Tab 后画面会变黑,直到用户点击黑屏区域,才会恢复显示。 这个问题通常是因为 Unity 渲染在 Tab 切换时被暂停或未能获得焦点所致。 在本文中,我们将介绍如何在使用 Layui 框架时,通过监听 Tab 切换事件并强制 Unity WebGL 渲染恢复,来解决这一问题。 1. 问题描述 当 Unity WebGL 内容嵌入到页面中的多个

By Ne0inhk