【多线程】线程互斥 {多执行流并发执行的数据竞争问题,互斥锁的基本用法,pthread_mutex系列函数,互斥锁的原理;死锁;可重入函数和线程安全}

一、进程线程间通信的相关概念

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

二、互斥锁

2.1 多执行流并发执行的数据竞争问题

  • 大部分情况,线程使用的数据都是局部变量,变量存储在线程栈空间内。这种情况,变量归属单个线程,其他线程无法访问这种变量。
  • 但有时候,很多变量都需要在线程间共享,这样的变量称为共享变量,可以通过数据的共享,完成线程之间的交互。比如,全局数据、堆空间数据。
  • 多个线程并发的操作共享变量,会带来数据竞争,冲突以及数据不一致等问题。

测试程序:

int tickets = 100; //共有100张票void *ThreadRoutine(void *name)
{while (1){if (tickets > 0){usleep(rand() % 1000); // 模拟业务过程花费的时间printf("%s sells ticket:%d\n", (char *)name, tickets);--tickets;}else{break;}usleep(rand() % 1000); // 模拟处理其他业务花费的时间}return nullptr;
}int main()
{srand((unsigned)time(nullptr));pthread_t tid1, tid2, tid3, tid4;pthread_create(&tid1, nullptr, ThreadRoutine, (void *)"child thread 1");pthread_create(&tid2, nullptr, ThreadRoutine, (void *)"child thread 2");pthread_create(&tid3, nullptr, ThreadRoutine, (void *)"child thread 3");pthread_create(&tid4, nullptr, ThreadRoutine, (void *)"child thread 4");pthread_join(tid1, nullptr);pthread_join(tid2, nullptr);pthread_join(tid3, nullptr);pthread_join(tid4, nullptr);return 0;
}

运行结果:

在这里插入图片描述

  1. 同一编号的票被多个线程售出
  2. 某些线程售出了负数编号的票

该程序存在数据竞争的问题,即公共变量tickets被多执行流同时访问和修改。

提示:除了多线程进程外,信号处理函数也是异步执行的(多执行流执行),同样存在数据竞争的问题。

并发运行问题

例如:tickets > 0--tickets操作并不是原子性操作,而是对应三条汇编指令:

  1. 将数据从内存加载到寄存器(当前线程的上下文中)
  2. 进行逻辑运算或算数运算
  3. 将数据写回内存

在这三条步骤的其中任何一步,该线程都有可能被切换,切换前线程上下文会被保存。其他线程在执行时也对tickets进行了访问和修改。当原线程再次被CPU调度执行时,恢复上下文数据,此时的寄存器与内存就会发生数据不一致的错误。

并行运行问题

多核CPU允许多线程并行(同时)运行。在ThreadRoutine函数中,由于没有对访问tickets的操作进行互斥,可能会导致多个线程同时读取和修改tickets变量,从而产生不可预测的结果。

例如:当多个线程同时执行if (tickets > 0)语句时,可能会出现以下情况:

  • 线程A和线程B同时读取tickets的值为1。
  • 线程A先执行--tickets操作,将tickets的值减为0。
  • 线程B再执行--tickets操作,将tickets的值减为-1。

这样,就会出现某些线程售出了负数编号的票。


2.2 互斥锁的基本用法

为了解决数据竞争问题,可以使用互斥锁(Mutex)来保护对tickets变量的访问。互斥锁可以确保在同一时间只有一个线程能够访问临界区(对tickets变量的访问),从而避免数据竞争的发生。

在这里插入图片描述

下面是互斥锁的基本使用方法:

  1. 定义互斥锁变量:在使用互斥锁之前,需要先定义一个互斥锁变量。可以使用pthread_mutex_t类型来声明互斥锁变量,例如:pthread_mutex_t mutex;
  2. 初始化互斥锁:在使用互斥锁之前,需要对互斥锁进行初始化。
    • 静态初始化:在定义互斥锁变量时,使用PTHREAD_MUTEX_INITIALIZER宏进行初始化。
    • 动态初始化:可以使用pthread_mutex_init函数来初始化互斥锁,例如:pthread_mutex_init(&mutex, NULL);。第一个参数是要初始化的互斥锁变量,第二个参数是互斥锁的属性,通常使用NULL表示使用默认属性;
  3. 加锁:在访问共享资源之前,需要先加锁。可以使用pthread_mutex_lock函数来加锁,例如:pthread_mutex_lock(&mutex);。如果互斥锁已经被其他线程锁定,那么当前线程会被阻塞,直到互斥锁被解锁。
  4. 访问共享资源:在互斥锁被锁定的情况下,可以安全地串行访问共享资源。
  5. 解锁:在访问共享资源完成后,需要解锁互斥锁,以便其他线程可以继续访问共享资源。可以使用pthread_mutex_unlock函数来解锁,例如:pthread_mutex_unlock(&mutex);
  6. 销毁互斥锁:
    • 不再需要使用互斥锁时,需要将其销毁。可以使用pthread_mutex_destroy函数来销毁互斥锁,例如:pthread_mutex_destroy(&mutex);
    • 静态初始化的互斥锁在程序结束时会自动被系统回收,无需手动销毁。
    • 不要销毁一个已经加锁的互斥量
    • 对于已经销毁的互斥量,要确保后面不会有线程再尝试加锁

我们将上面的售票程序加入互斥锁:

int tickets = 100; //临界资源
// 定义一个全局的互斥锁变量,并利用宏进行初始化(静态初始化)
pthread_mutex_t mtx = PTHREAD_MUTEX_INITIALIZER;void *ThreadRoutine(void *name)
{while (1){// 在访问共享资源之前,需要先加锁。pthread_mutex_lock(&mtx);//临界区if (tickets > 0){usleep(rand() % 1000); // 模拟业务过程花费的时间printf("%s sells ticket:%d\n", (char *)name, tickets);--tickets;// 在访问共享资源完成后,需要解锁互斥锁。pthread_mutex_unlock(&mtx);}else{// 在访问共享资源完成后,需要解锁互斥锁。pthread_mutex_unlock(&mtx);break;}// 在此处解锁?不行,如果线程执行break,就不会解锁互斥锁。其他线程会被一直阻塞。usleep(rand() % 1000); // 模拟处理其他业务花费的时间}return nullptr;
}

运行结果:

在这里插入图片描述

需要注意的几点:

  1. 在访问共享资源完成后,需要解锁互斥锁。否则,其他线程会被一直阻塞。需要特别注意break, goto等跳转语句跳过解锁函数。

  2. 被互斥锁锁定的临界区只能串行执行(互斥访问),虽然保证了多执行流访问临界资源的安全性,但是会在一定程度上降低程序的效率

  3. 尽量保证被互斥锁锁定的代码都是访问临界资源的代码,不要将其他无关的操作也放入临界区中。因为相比并发或并行执行,临界区串行执行的效率较低。

再次改进上面的代码:

#define THREAD_NUM 5
int tickets = 100;//声明一个ThreadData类,使线程入口函数的参数更多样化。
class ThreadData
{
public:string _tname; //线程名pthread_mutex_t *_pmtx; //互斥锁变量的地址ThreadData(const string &tname, pthread_mutex_t *pmtx): _tname(tname),_pmtx(pmtx){};
};void *ThreadRoutine(void *arg)
{ThreadData *td = (ThreadData *)arg;while (1){// 在访问临界资源前进行加锁pthread_mutex_lock(td->_pmtx);if (tickets > 0){usleep(rand() % 1000); // 模拟业务过程花费的时间printf("%s sells ticket:%d\n", td->_tname.c_str(), tickets);--tickets;// 不再访问临界资源时需要解锁。pthread_mutex_unlock(td->_pmtx);}else{// 不再访问临界资源时需要解锁。pthread_mutex_unlock(td->_pmtx);break;}usleep(rand() % 1000); // 模拟处理其他业务花费的时间}delete td; // 释放各自的ThreadData结构空间return nullptr;
}int main()
{srand((unsigned)time(nullptr));// 在主线程栈区创建互斥锁变量pthread_mutex_t mtx;// 调用pthread_mutex_init初始化互斥锁(动态初始化)pthread_mutex_init(&mtx, nullptr);// 循环创建子线程pthread_t tid[THREAD_NUM];for (int i = 0; i < THREAD_NUM; ++i){string tmp = "child thread ";tmp += to_string(i + 1);ThreadData *td = new ThreadData(tmp, &mtx);pthread_create(tid + i, nullptr, ThreadRoutine, td); //传入ThreadData对象的指针}// 循环等待子线程for (int i = 0; i < THREAD_NUM; ++i){pthread_join(tid[i], nullptr);}// 在不再需要使用互斥锁时,需要将其销毁。(动态初始化的互斥锁需要进行销毁,而静态初始化不需要)pthread_mutex_destroy(&mtx);return 0;
}

新的问题:

  1. 加锁了之后,线程在执行临界区代码时,是否会被切换,会有问题吗?
    会被切换,但不会有问题!虽然被切换了,但是你是持有锁被切换的, 所以其他抢票线程要执行临界区代码,也必须先申请锁,而它是无法申请成功的。所以,也不会让其他线程进入临界区,就保证了临界区中数据一致性!

  2. 对于访问临界资源的线程而言,临界区代码要么全部执行成功,要么全部不执行,访问临界资源的操作不可被中断(不能同时执行其他线程的临界区代码),这就是原子性的体现。

  3. 要访问临界资源,每一个线程都必须先申请锁,而锁本身就是一种共享资源,那么谁来保证锁的安全呢?

    所以,为了保证锁的安全,申请和释放锁,必须是原子的!


2.3 互斥锁的原理

  1. 在汇编层面,一条汇编语句要么已经执行完,要么就还没有执行,是原子性的。
  2. 为了实现互斥锁操作,大多数体系结构都提供了swap或exchange汇编指令,该指令的作用是把寄存器和内存单元的数据相交换,由于只有一条指令,保证了原子性。即使是多处理器平台(并行),访问内存的总线周期也有先后,一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期。
  3. CPU的寄存器数据,本质就是当前执行流的上下文。寄存器的存储空间被所有执行流共享,但是寄存器的内容是被每一个执行流私有的。所以在切换线程时,要将当前线程的寄存器数据保存到其PCB中,并恢复下一个线程的寄存器数据。

以下是加锁的核心汇编伪代码:

lock:movb $0, %al // 将数值0,move到al寄存器中xchgb %al, mutex //交换al寄存器与mutex变量(内存)的数据if(al寄存器的内容 > 0){return 0;}else挂起等待;goto lock; //跳转到lock标签,再次申请锁
  1. 我们可以将互斥锁变量mutex理解成一个整形变量,值为1表示互斥锁未被线程持有;值为0,表示互斥锁已经被其他线程锁定。创建互斥锁变量并进行初始化后,其默认值为1。
  2. 由于exchange汇编指令是原子的,所以不管线程如何切换,只有一个线程能够将mutex(内存)中的1值交换到自己的寄存器当中,即该线程的上下文中。而线程上下文是线程的私有数据,实现了公有到私有的转换。同时寄存器当中的0值被交换到了mutex中,其他线程再进行交换也只能交换到0。
  3. 在进行if判断时,交换到1值的线程执行return 0,可以安全地进入临界区访问临界资源;而交换到0值的线程阻塞等待,直到互斥锁被解锁,这些线程才会被唤醒,然后再次尝试申请锁。

以下是解锁的核心汇编伪代码:

unlock:movb $1, mutex //将数值1,move到mutex变量(内存)唤醒等待mutex的线程;return 0;
  1. 当持有锁的线程访问完临界资源后,会将mutex变量重新置为1,即解锁互斥锁。
  2. 同时,应该唤醒等待互斥锁解锁的线程,让他们再次竞争申请锁。

回答之前的问题:

  1. 谁来保证锁的安全呢?

    为了保证锁的安全,申请和释放锁,必须是原子的!在设计加锁时,通过一条原子性的exchange指令,保证了加锁和解锁的原子性。

  2. 加锁了之后,线程在临界区中,是否会切换,会有问题吗?

    线程在临界区中也可能会被切换,但他是持有锁被切换的,所谓持有锁切换是指互斥锁的1值保存在当前线程的上下文,被当前线程私有。而其他线程即使被CPU调度执行,也无法抢占互斥锁,也就无法访问临界区代码。所以不会有任何问题。


三、可重入函数和线程安全

  • 可重入函数:同一个函数被多个执行流同时进入,就叫重入。如果该函数在被重入执行的过程中不会出现任何错误,则被称为可重入函数。反之就是不可重入函数。
  • 线程安全:多个线程并发执行时,在没有锁保护的情况下访问了共享资源(如全局或静态变量,堆区数据等),会出现数据竞争从而导致数据冲突,数据不一致等线程安全问题。

3.1 线程安全的情况

  1. 仅使用本地(局部)数据,或者通过制作全局数据的本地拷贝来保护全局数据。
  2. 使用互斥锁(Mutex)来保护对共享资源的访问。互斥锁可以确保在同一时间只有一个线程能够访问临界区,从而避免数据竞争的发生。
  3. 每个线程对共享资源只有读取的权限,而没有写入的权限,一般来说这些线程是安全的。
  4. 不调用线程不安全的函数

3.2 可重入函数的情况

  1. 仅使用本地(局部)数据,或者通过制作全局数据的本地拷贝来保护全局数据。
  2. 使用互斥锁(Mutex)来保护对共享资源的访问。如全局、静态变量或其他共享资源。
  3. 不调用不可重入函数

常见不可重入的情况

  1. 调用了malloc/free函数,因为Linux内核是用全局链表和全局红黑树结构来组织和管理堆空间的。(请看提示)
  2. 调用了标准I/O库函数,因为标准I/O库的很多实现都以不可重入的方式使用了全局数据结构。

提示:

  1. 关于Linux内核中的堆区管理,请阅读:【多线程】线程的概念 {Linux内核中的堆区管理;虚拟地址到物理地址的转换,页,页框,页表,MMU内存管理单元;Linux线程概念,轻量级进程;线程共享进程的资源;线程的优缺点;线程的用途}-CSDN博客
  2. 关于多执行流调用不可重入函数插入链表节点,请阅读:【信号】信号处理 {信号处理的时机;内核态和用户态;信号捕捉的原理;信号处理函数:signal, sigaction;可重入函数;volatile关键字;SIGCHLD信号}-CSDN博客

3.3 区别和联系

联系

  1. 函数是可重入的,那就是线程安全的。
  2. 函数是不可重入的,那就不能由多个线程使用,有可能引发线程安全问题。

区别

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

四、死锁

死锁(Deadlock)是指在并发系统中,两个或多个进程(或线程)因为互相等待对方释放资源而无法继续执行的状态。在死锁状态下,进程无法前进,也无法释放资源,导致系统无法正常运行。

死锁通常发生在多个进程(或线程)同时竞争有限的资源时,每个进程都在等待其他进程释放资源,而自己又无法释放已经占有的资源。

在这里插入图片描述

特殊情况:一个执行流,一把互斥锁也可能导致死锁,即加锁后,不解锁,再次申请锁。

死锁的发生需要满足以下四个条件,也被称为死锁的必要条件:

  1. 互斥条件:一个资源每次只能被一个执行流使用(不加锁自然就不会产生死锁)。

  2. 请求与保持条件:一个执行流因请求资源而阻塞时,对已获得的资源保持不放。

  3. 不剥夺条件:一个执行流已获得的资源,在未使用完之前,不能强行剥夺。

  4. 循环等待条件:若干执行流之间形成一种头尾相接的循环等待资源的关系,使得每个执行流都在等待下一个执行流所占有的资源。

当这四个条件同时满足时,就可能发生死锁。一旦发生死锁,系统将无法自动解除死锁状态,需要通过人工干预来解决。

为了避免死锁的发生,可以采取以下策略:

  1. 破坏互斥条件:例如,允许多个进程(或线程)同时访问某些资源。

  2. 破坏请求与保持条件:例如,要求进程(或线程)在执行之前一次性获取所有需要的资源,否则在等待资源时释放已经占有的资源。

  3. 破坏不可剥夺条件:例如,允许系统强制剥夺某些进程(或线程)的资源。

  4. 破坏循环等待条件:例如,通过对资源进行排序,按照固定的顺序申请资源,避免交叉申请,循环等待。(T1,T2都先申请R1再申请R2)

  5. 其他方法:精简临界区代码,缩短持有锁的时间;合并临界区,资源一次性分配(一把锁);

死锁是并发系统中的一个重要问题,对系统的性能和可靠性有很大影响。因此,在设计和实现并发系统时,需要合理地分配和管理资源,以避免死锁的发生。

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

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

相关文章

计算机网络基础三

课程目标 理解路由表的作用 能够读懂路由表信息 能够使用图形抓包工具 wireshark 进行数据包的抓取 &#xff0c;如&#xff08; TCP/IP 的三次握手四次断开&#xff09; 一、路由表 思考&#xff1a; 什么是交换,什么是路由,什么是路由表&#xff1f;1. 交换是指同网络访…

Linux C语言开发-D15一维数组

数组&#xff1a;有一定顺序关系的数据类型相同变量的变量集合 形式&#xff1a;<存储类型> <数据类型> <数组名> [<表达式>] 数组名表示内存首地址&#xff0c;是一个地址常量&#xff0c;sizeof(数组名)是数组占用的总内存空间 编译时分配连续内存…

常见的22个软件测试面试题(含答案解析)

大家好&#xff0c;我是大圣。今天大圣给大家列举了API测试的22个面试题&#xff0c;快来看看吧。 1、什么是API? API是允许两个应用程序相互通信的代码。API使开发人员能够发出特定的调用或请求来发送或接收信息。 2、什么是以API为中心的应用程序? 以API为中心的应用程…

重置 VCSA 6.7 root密码和SSO密码

原贴地址&#xff1a;https://www.cnblogs.com/airoot/p/16059033.html 问题描述 1、用root用户登录 VMware vCenter Server Appliance虚拟机失败&#xff0c;无法登录 2、vCenter Server Appliance 6.7 U1的root帐户错误尝试次数超过3次已锁定或帐户已过期 官方说明 在VC…

帆软同时查看多个tab会卡换种方式用网页跳转就会提升效率

效果如图&#xff1a; 方法&#xff1a; 首先&#xff0c;要下载个插件–网页框控件&#xff1b; 接着&#xff0c;做个frm作为首页&#xff0c; 把地址和参数输入进去 最后&#xff0c;预览首页就可以了

【软件教程】如何用C++检查TCP或UDP端口是否被占用

一、检查步骤 使用socket函数创建socket_fd套接字。使用sockaddr_in结构体配置协议和端口号。使用bind函数尝试与端口进行绑定&#xff0c;成功返回0表示未被占用&#xff0c;失败返回-1表示已被占用。 二、CODE 其中port需要修改为想要检测的端口号&#xff0c;也可以将代码…

检测和缓解SQL注入攻击

SQL注入即是指web应用程序对用户输入数据的合法性没有判断或过滤不严&#xff0c;攻击者可以在web应用程序中事先定义好的查询语句的结尾上添加额外的SQL语句&#xff0c;在管理员不知情的情况下实现非法操作&#xff0c;以此来实现欺骗数据库服务器执行非授权的任意查询&#…

Jetpack Compose | State状态管理及界面刷新

我们知道Jetpack Compose&#xff08;以下简称Compose&#xff09;中的 UI 可组合项是通过Composable 声明的函数来描述的&#xff0c;如&#xff1a; Composable fun Greeting() {Text(text "init",color Color.Red,modifier Modifier.fillMaxWidth()) }上面的代…

研究人员发现基于xmpp的即时通讯服务被窃听

攻击者使用我们的加密服务发布了几个新的TLS证书,这些服务被用来劫持加密的 星连接 在5222端口使用透明的[中间人]代理。 到目前为止收集到的证据指向在托管提供者网络上配置的流量重定向,排除了其他可能性&#xff0c;例如服务器中断或欺骗攻击。 据估计&#xff0c;窃听从20…

数据结构:优先级队列(堆)

概念 优先级队列是啥&#xff1f; 队列是一种先进先出 (FIFO) 的数据结构 &#xff0c;但有些情况下&#xff0c; 操作的数据可能带有优先级&#xff0c;一般出队 列时&#xff0c;可能需要优先级高的元素先出队列。 在这种情况下&#xff0c; 数据结构应该提供两个最基本的…

uniapp开发小程序 小米手机真机bottom:0无效 底部间隙 设备安全区域处理办法

uniApp自定义导航 CSS设置 bottom:0竟然无效&#xff0c;而iphone和开发模拟器没有问题 height: 150rpx;position: fixed;left: 0;right: 0;bottom: calc(var(--window-bottom,0)); 网上查了各种方法&#xff0c;包括设置bottom:-20啊以及 padding-bottom: constant(safe-are…

Spark On Hive原理和配置

目录 一、Spark On Hive原理 &#xff08;1&#xff09;为什么要让Spark On Hive&#xff1f; 二、MySQL安装配置&#xff08;root用户&#xff09; &#xff08;1&#xff09;安装MySQL &#xff08;2&#xff09;启动MySQL设置开机启动 &#xff08;3&#xff09;修改MySQL…

后悔没早学这份Python神级文档!2023最新入门到进阶核心知识点学习文档!

如今学 Python 的程序员越来越多&#xff0c;甚至不少人会把 Python 当作第一语言来学习。不过尽管 Python 功能强大上手轻松&#xff0c;但并不代表它的学习曲线不陡峭&#xff0c;得来全不费工夫。 当推开 Python 的大门&#xff0c;你会发现 Python 入门简单但精通很难。看…

Realrek 2.5G交换机 8+1万兆光RTL8373-VB-CG方案简介

新一代2.5G交换机方案RTL8373-VB-CG可以提供4中不同形态 a. 52.5G 电口110G光》RTL8373 b. 52.5G 电口110G电》RTL83738261 c. 82.5G 电口110G光》RTL83738224 d.82.5G 电口110G电口》RTL837382248261 1.概述 Realtek RTL8373-CG是一款低功耗、高性能、高度集成的八端口2.5G和一…

(c语言进阶)字符串函数、字符分类函数和字符转换函数

一.求字符串长度 1.strlen() (1)基本概念 头文件&#xff1a;<string.h> (2)易错点&#xff1a;strlen()的返回值为无符号整形 #include<stdio.h> #include<string.h> int main() {const char* str1 "abcdef";const char* str2 "bbb&q…

N-129基于springboot,vue学生宿舍管理系统

开发工具&#xff1a;IDEA 服务器&#xff1a;Tomcat9.0&#xff0c; jdk1.8 项目构建&#xff1a;maven 数据库&#xff1a;mysql5.7 系统分前后台&#xff0c;项目采用前后端分离 前端技术&#xff1a;vuevue-element-admin 服务端技术&#xff1a;springboot,mybatis…

css矩形盒子实现虚线流动边框+css实现step连接箭头

由于项目里需要手写步骤条 且实现指定状态边框虚线流动效果&#xff0c;故使用css去绘制步骤条连接箭头和绘制边框流动效果 效果&#xff1a; 1.绘制步骤条连接箭头 <ul class"process-list"><div v-for"(process, index) in processes" :key&qu…

论文阅读——DistilBERT

ArXiv&#xff1a;https://arxiv.org/abs/1910.01108 Train Loss: DistilBERT&#xff1a; DistilBERT具有与BERT相同的一般结构&#xff0c;层数减少2倍&#xff0c;移除token类型嵌入和pooler。从老师那里取一层来初始化学生。 The token-type embeddings and the pooler a…

UEditorPlus v3.6.0 图标补全,精简代码,快捷操作重构,问题修复

UEditor是由百度开发的所见即所得的开源富文本编辑器&#xff0c;基于MIT开源协议&#xff0c;该富文本编辑器帮助不少网站开发者解决富文本编辑器的难点。 UEditorPlus 是有 ModStart 团队基于 UEditor 二次开发的富文本编辑器&#xff0c;主要做了样式的定制&#xff0c;更符…

Wpf 使用 Prism 实战开发Day03

一.实现左侧菜单绑定 效果图: 1.首先需要在项目中创建 mvvm 的架构模式 创建 Models &#xff0c;放置实体类。 实体类需要继承自Prism 框架的 BindableBase&#xff0c;目的是让实体类支持数据的动态变更! 例如: 系统导航菜单实体类 / <summary>/// 系统导航菜单实体类…