qt 5编程入门(第2版)_《C++并发编程实战第2版》第六章:设计基于锁的并发数据结构(1/3)...

本章主要内容

  • 设计并发数据结构的含义
  • 设计指南
  • 并发数据结构的示例实现

在上一章中我们了解了底层原子操作和内存模型。本章我们先把底层的细节放一放(尽管在第7章我们将需要它们),探讨一下数据结构。

为编程问题选择数据结构可能是整个解决方案的关键部分,并行程序也不例外。如果一个数据结构需要被多个线程访问,要么它完全不可变,因此数据永远不会变化,并且没有必要同步,要么程序必须设计成确保变动在线程间被正确的同步。一种选择是使用独立的互斥锁以及外部加锁来保护数据,比如使用我们在第3和第4章讨论的技术,另一种选择是设计自身支持并发访问的数据结构。

当设计并发数据结构时,可以使用前面章节提及的应用于多线程程序的基础构件块,比如:互斥锁和条件变量。实际上,你已经看过了几个示例,这些示例展示了如何组合这些构建块来编写对多线程并发访问安全的数据结构。

在本章中,我们将从为并发设计数据结构的一些通用指南开始。然后,我们将使用锁和条件变量的基本构建块,并在转向更复杂的数据结构之前重新讨论这些基本数据结构的设计。在第7章中,我们将看到如何回归基础,并使用第5章中描述的原子操作来构建无锁的数据结构。

好了!言归正传,让我们来看一下设计并发数据结构都需要什么。

6.1 并发设计的含义

从基本层面上讲,为并发设计数据结构意味着,多个线程可以并发的访问这个数据结构,不管线程执行的是相同还是不同的操作,并且每一个线程都能看到数据结构的前后一致的视图。没有数据丢失或者损坏,所有的不变量都被支持,且没有有问题的竞争条件。这样的数据结构,称之为线程安全(thread-safe)的数据结构。通常情况下,一个数据结构只对特殊类型的并发访问是安全的。也许有可能让多个线程并发地对数据结构执行一种类型的操作,而另一种操作则需要由单个线程独占访问。或者,如果多个线程正在执行不同的操作,那它们并发访问数据结构可能是安全的,但多个线程执行相同的操作就会有问题。

然而,真正为并发而设计远不止这些:真正的设计意味着要为线程提供并发访问数据结构的机会。从本质上讲,互斥锁提供了互斥:一次只能有一个线程获得互斥锁。互斥锁保护数据结构是通过显式地阻止对它所保护数据的真正并发访问来实现的。

这称为串行化(serialzation):线程轮流访问被互斥锁保护的数据。它们必须串行而非并发的访问它。因此,必须仔细考虑数据结构的设计,使得能够真正的并发访问。虽然有些数据结构比其他数据结构具有更大的并发范围,但在所有情况下,其思想都是相同的:受保护的区域越小,串行化的操作就越少,并发的潜力也就越大。

在进行数据结构的设计之前,让我们快速的浏览一下并发设计的指南。

6.1.1 设计并发数据结构的指南

之前提过,当设计并访问的数据结构时,需要考虑两个方面:确保访问安全以及允许真正的并发访问。在第3章中,已经介绍了如何使数据结构是线程安全的基础知识:

  • 确保没有线程能够看到数据结构的不变量被另一个线程破坏的状态。
  • 通过提供完整操作的函数,而非一个个操作步骤的函数来小心避免接口固有的竞争条件。
  • 注意数据结构在有异常时的行为,从而确保不变量不会被破坏。
  • 通过限制锁的范围以及避免嵌套锁,将死锁的概率降到最低。

在思考这些细节之前,想想要对数据结构的用户施加什么约束也是很重要的;如果线程通过一个特定的函数对数据结构进行访问,其他线程能安全调用哪些函数?

这是一个需要考虑的关键问题,通常,构造函数和析构函数需要互斥地访问数据结构,但是需要由用户确保它们不会在构造函数完成之前或者析构函数开始以后被访问。如果数据结构支持赋值操作,swap()或拷贝构造,作为数据结构的设计者,你需要决定这些操作与其他操作并发调用是否安全,或者它们是否要求用户确保独占访问,尽管大多数用于操作数据结构的函数可以从多个线程并发地调用而没有任何问题。

第二个需要考虑的方面是允许真正的并发访问。在这个方面我没法提供太多的指南。相反,作为一个数据结构的设计者,需要问自己以下问题:

  • 是否可以限制锁的作用范围,以允许操作的某些部分在锁外执行?
  • 数据结构不同部分能否被不同的互斥锁保护?
  • 所有的操作需要同一级别的保护吗?
  • 是否可以对数据结构进行简单的修改,以增加并发访问的机会,并且不影响操作语义?

所有这些问题都基于一个思想:如何最小化必须的串行操作,并且使得真实的并发最大化?就数据结构而言,允许多线程并发的只读访问,而修改线程必须互斥访问的情况很普遍。这是通过使用像std::shared_mutex这样的结构来支持的。类似地,你很快就会看到,在串行线程尝试执行相同操作的同时,数据结构支持执行不同操作的线程并发地访问也很普遍。

最简单的线程安全数据结构,通常使用互斥锁来保护数据。尽管这样做存在一些问题,但就像你在第3章中看到的,确保一次只有一个线程访问数据结构相对比较简单。为了让你更容易设计线程安全的数据结构,我们将在本章继续研究这种基于锁的数据结构,并将无锁并发数据结构的设计留到第7章讨论。

6.2 基于锁的并发数据结构

设计基于锁的并发数据结构,都是为了确保在访问数据时锁住正确的互斥锁,并且持有锁的时间最短。对于只有一个互斥锁的数据结构来说,这很困难。你需要确保数据不能在互斥锁的保护之外被访问,并且接口中没有固有的竞争条件,就如第3章中看到的那样。如果使用不同的互斥锁来保护数据结构中不同的部分,问题会进一步恶化,如果操作需要锁住多个互斥锁时,现在也可能产生死锁。所以相比单一互斥锁的设计,使用多个互斥锁的数据结构需要更加小心。

在本节中,你将应用6.1.1节中的指南来设计一些简单的数据结构,通过使用互斥锁来保护数据。在每个例子中,都是在确保数据结构保持线程安全的前提下,找出更大并发的机会。

我们先来看看第3章中栈的实现,它是最简单的数据结构,且只使用了一个互斥锁。那么它是线程安全的吗?它离真正的并发访问有多远呢?

6.2.1 使用锁的线程安全栈

下面的清单复制了第3章中线程安全的栈。目的是编写一个类似于std::stack<>的线程安全的数据结构,它支持将数据项推入栈中并再次弹出它们。

b8a93aad4aef54cac742729d29365319.png

68e8cdc4af11208201b3b43243833d84.png

我们依次看下每条指南以及它们是如何应用在这里。

首先,如你所见,基本的线程安全是通过使用互斥锁m上的锁保护每个成员函数提供的。这将确保在任何时候只有一个线程在访问数据,因此只要每个成员函数保持不变量,就没有线程能看到被破坏的不变量。

其次,在empty()和pop()成员函数之间有潜在的竞争条件,不过代码会在pop()函数持有锁的时候,显式的查询栈是否为空,所以这里的竞争条件没有问题。通过直接返回弹出的数据项作为调用pop()的一部分,避免了分离的top()和pop()成员函数(std::stack<>类似)之间潜在的竞争条件。

然后,栈中也有一些潜在抛异常的地方。对互斥锁上锁可能会抛出异常,但这种情况不仅极其罕见的(这意味着互斥锁有问题,或者缺乏系统资源),而且它是每个成员函数的第一个操作。由于没有数据被修改,所以是安全的。解锁互斥锁不会失败,所以总是安全的,并且使用std::lock_guard<>确保了互斥锁不会一直处于上锁的状态。

对data.push()①的调用可能会抛出一个异常,只要拷贝/移动数据值抛出一个异常,或者可分配的内存不足。不管是哪种情况,std::stack<>都能保证是安全的,所以也没有问题。

在第一个重载的pop()中,代码本身可能会抛出一个empty_stack的异常②,但由于什么都没有修改,所以是安全的。创建res③可能会抛出一个异常,有几个方面的原因:对std::make_shared的调用,可能因为无法为新对象以及引用计数需要的内部数据分配出足够的内存而抛出异常,或者在拷贝/移动到新分配内存的时候,返回的数据项的拷贝构造或移动构造函数可能抛出异常。两种情况下,C++运行库和标准库会确保没有内存泄露,并且新创建的对象(如果有的话)会被正确的销毁。因为仍然没有对栈进行任何修改,所以也不会有问题。调用data.pop()④保证不会抛出异常,随后是返回结果,所以这个重载的pop()函数是异常安全的。

第二个重载的pop()类似,不过这次是在拷贝赋值或移动赋值时可能抛出异常⑤,而不是在构造新对象和std::shared_ptr实例时。再次,直到调用data.pop()⑥(pop仍然保证不会抛出异常)前,没有修改数据结构,所以这个函数也是异常安全的。

最后,empty()不会修改任何数据,所以也是异常安全的。

这里有几个可能导致死锁的机会,因为你在持有锁的时候调用了用户代码:数据项上的拷贝构造或移动构造(①,③)和拷贝赋值或移动赋值操作⑤,也可能是用户自定义的new操作符。如果这些函数或者调用了栈上的成员函数(而栈正在插入或移除数据项),或者需要任何类型的锁,而在调用栈成员函数时又持有了另一把锁,那么就有可能出现死锁。但明智的做法是要求栈的用户负责确保这一点;你不能期望在不拷贝或不为它分配内存的情况下将数据项添加到栈或从栈中删除。

由于所有成员函数都使用std::lock_guard<>保护数据,所以不管多少线程调用栈成员函数都是安全的。唯一不安全的成员函数是构造函数和析构函数,但这不是问题;对象只能被构造一次,也只能被销毁一次。调用一个不完全构造的对象或是部分销毁的对象的成员函数永远都不可取,不管并发与否。因此,用户必须确保其他线程直到栈完全构造才能访问它,,并且必须确保在栈对象销毁前,所有线程都已经停止访问栈。

尽管多个线程并发调用成员函数是安全的,但由于使用了锁,每次只有一个线程在栈数据结构中做一些工作。线程的串行化会潜在的限制应用程序的性能,因为这里会有严重的锁争用:当一个线程在等待锁时,它没有做任何有用的工作。同样,栈也没有提供什么方法等待添加一个数据项,所以如果线程需要等待时,它必须周期性地调用empty()或pop(),并且捕获empty_stack异常。如果这种场景是必须的,那这种栈实现就是个糟糕的选择,因为等待线程要么消耗宝贵的资源去检查数据,要么要求用户编写外部等待和通知的代码(例如,使用条件变量),这就使内部上锁没有必要,因而造成浪费。第4章中的队列展示了一种使用数据结构内部的条件变量将这种等待合并到数据结构本身的方法,接下来我们看一下这个。

6.2.2使用锁和条件变量的线程安全队列

清单6.2复制了第4章中的线程安全队列,就像栈是仿照std::stack<>一样,这个队列也是仿照了std::queue<>。再次,接口不同于标准容器适配器,因为实现的数据结构需要支持多线程并发访问。

5eaa30fc2c993da474deea3a679fe6a1.png

fd88e7308e626e99e267e0a4e51980be.png

除了在push()①中调用data_cond.notify_one(),以及wait_and_pop()②③外,清单6.2中队列的实现与6.1清单中的栈类似。两个重载的try_pop()几乎和清单6.1中一样,只是在队列为空时不抛异常,取而代之返回一个bool值表示是否检索到值或者一个NULL指针(对应返回指针的重载版本)如果没有值可以检索的话。这也是实现栈的一个有效方式。如果排除wait_and_pop()函数,对栈的分析在这里也同样适用。

新的wait_and_pop()函数解决了在栈中碰到的等待队列条目的问题;比起持续调用empty(),等待线程调用wait_and_pop()函数并且数据结构使用条件变量来处理等待。对data_cond.wait()的调用,直到队列中至少有一个元素时才会返回,所以不用担心会出现空队列的情况,并且数据仍然被互斥锁保护。因此,这些函数不会添加任何新的竞争条件或死锁的可能性,并且将支持不变量。

在异常安全性方面有一个细微的变化,当一个条目被推入队列时,如果有多个线程在等待,那么只有一个线程会被data_cond.notify_one()唤醒。但是,如果这个线程在wait_and_pop()中抛出一个异常,比如当构造新的std::shared_ptr<>对象④时,那么没有其他线程被唤醒。这种情况不可接受,调用可以替换成data_cond.notify_all(),它将唤醒所有的工作线程,代价就是大多线程发现队列依旧是空时,重新进入休眠状态。第二种替代方案是,有异常抛出的时,让wait_and_pop()函数调用notify_one(),从而让另一个线程可以去尝试检索存储的值。第三种替代方案是,将std::shared_ptr<>的初始化过程移到push()中,并且存储std::shared_ptr<>实例,而不是直接使用数据值。将std::shared_ptr<>从内部std::queue<>中拷出不会抛出异常,这样wait_and_pop()又是安全的了。下面的程序清单,就是基于这种思路修改的。

ccb552dff15d84b4ad9e552a58af6dd7.png

1b4154f6d2b2580233c9f9e3eca0b9c5.png

通过std::shared_ptr<>持有数据的影响比较直接:通过引用变量来接收新值的pop函数现在必须对存储的指针解引用①②;并且,在返回给调用者前,返回std::shared_ptr<>实例的pop函数可以从队列中检索它③④。

通过std::shared_ptr<>持有数据还有个好处:在push()⑤中分配新实例可以在锁外面完成,而在清单6.2中,只能在pop()持有锁时完成。因为内存分配是个典型的代价高昂的操作,这有利于队列的性能,因为它减少了持有互斥锁的时间,并允许其他线程同时在队列上执行操作。

如同栈示例,使用互斥锁来保护整个数据结构限制了该队列的并发支持;尽管在不同的成员函数中,队列上可能阻塞多个线程,但一次只能有一个线程开展工作。但是部分限制来自于实现中使用了std::queue<>;通过使用标准容器,你现在可以决定数据项是否受保护。通过控制数据结构的实现细节,你可以提供更细粒度的锁从而实现更高级别的并发。

6.2.3使用细粒度锁和条件变量的线程安全队列

在清单6.2和6.3中,有一个受保护的数据项(data_queue)和一个互斥锁。为了使用细粒度锁,需要查看队列内部的组成部分,并将一个互斥锁与每个不同的数据项关联起来。

最简单的队列是单链表,如图6.1所示。队列包含一个头指针,指向链表中的第一个项,然后每一项指向下一项。从队列中删除数据项,是用指向下一项的指针替换头指针,然后将之前头指针的数据返回。

数据项从队列的另一端添加到队列。为了做到这点,队列还有一个tail指针,它指向链表中的最后一项。新节点的添加是通过改变最后一项的next指针,让它指向新的节点,然后更新tail指针指向这个新的数据项。当链表为空时,头/尾指针都为NULL。

e78a90da0cc39cf8b8655a811c08c94c.png
图6.1 用单链表表示的队列

下面的清单显示了这个队列的简单实现,它基于清单6.2中队列接口的简化版本;只有一个try_pop()函数,没有wait_and_pop(),因为这个队列只支持单线程使用。

859bbeba6c3af62aaedd03dbf6bc6c90.png

bafa13a7143b60322c505d5400559c2a.png

首先,注意清单6.4中使用了std::unique_ptr<node>来管理节点,因为这能保证当不再需要它们的时候,它们(以及它们引用的数据)会自动删除,而不必使用显式的delete。这个所有权链的管理从head开始,tail是指向最后一个节点的裸指针,因为它需要引用std::unique_ptr<node>已经拥有的节点。

虽然这个实现在单线程环境工作的很好,但当在多线程下尝试使用细粒度锁时,有几个事情会带来麻烦。因为在给定的实现中有两个数据项(head①和tail②);原则上可以使用两个互斥锁来分别保护头和尾指针,但这样做会有几个问题。

最明显的问题就是push()可能同时修改head⑤和tail⑥,所以它必须锁住两个互斥锁。尽管很不幸,但这倒不算是太大的问题,因为锁住两个互斥锁是可能的。关键的问题是push()和pop()都能访问next指针指向的节点:push()更新tail->next④,然后try_pop()读取head->next③。如果队列中只有一个元素,那么head==tail,所以head->next和tail->next是同一个对象,并且这个对象需要保护。由于不同时读取head和tail的话,没法区分它们是否是同一个对象,你现在必须在push()和try_pop()中锁住同一个锁,所以,也没比以前好多少。那有什么办法摆脱这个困境吗?

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

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

相关文章

转学到斯坦福大学计算机专业,斯坦福大学转学申请条件有哪些?

斯坦福大学转学申请条件有哪些&#xff1f;据托普仕小编了解&#xff0c;斯坦福大学(Stanford University&#xff0c;简称Stanford)位于美国加州旧金山湾区南部的帕罗奥多市(PaloAlto)境内&#xff0c;临近世界著名高科技园区硅谷&#xff0c;是世界著名私立研究型大学。斯坦福…

深入理解Asp.net MVC路由

深入理解Asp.net MVC路由 吴剑 2012-10-22 原创文章&#xff0c;转载必需注明出处&#xff1a;http://www.cnblogs.com/wu-jian/ 前言 从.Net Framework 1.0时代开始写WebForm&#xff0c;直到最近断断续续看到Razor的语法风格&#xff0c;然后搜了Asp.net MVC的一些介绍&#…

高中计算机学业水平测试知识点总结,高中化学学业水平测试知识点总结

高中化学学业水平测试知识点总结大家有总结吗&#xff1f;下面小编整理了高中化学学业水平测试知识点总结&#xff0c;欢迎大家参考借鉴&#xff01;高中化学学业水平测试知识点总结第一章从实验学化学 第一节化学实验基本方法一.化学实验安全1. 遵守实验室规则。2. 了解安全措…

chrome html 读写文件路径,Chrome浏览器支持直接读写本地文件了

本文首发于公众号&#xff1a;符合预期的CoyPan写在前面最新版的Chrome(Chrome 83, 须要开启权限)支持直接读写本地文件了。javascript开启方法&#xff1a;Chrome浏览器升级到83版本以上&#xff1b;访问chrome://flags/&#xff0c;开启 Native File System API 选项当前如何…

最长单调递增子序列_最长递增子序列(动态规划 + 二分搜索)

题目给定数组arr&#xff0c;返回arr的最长递增子序列举例&#xff1a;arr [2,1,5,3,6,4,8,9,7]&#xff0c;返回的最长递增子序列为[1,3,4,8,9]要求&#xff1a;如果arr的长度为N&#xff0c;请实现时间复杂度为O(nlogn)的方法。分析这一题也是经典的动态规划&#xff0c;那么…

二叉排序树(BinarySortTree)

相关知识&#xff1a; 二叉树是每个节点最多有两个子树的树结构。通常子树被称作“左子树”&#xff08;left subtree&#xff09;和“右子树”&#xff08;right subtree&#xff09;。 二叉树中的左右子树不可随意交换。 根节点&#xff1a;一棵树最上面的节点称为根节点。 父…

惠普台式计算机系列,惠普发布设计笔记本、设计台式电脑等Z系列产品

惠普发布新一代惠普Z系列产品&#xff0c;包含设计笔记本、设计台式电脑、显示器和VR等产品。惠普Z系列设计笔记本HP ZBook 14u G6配有4K显示屏&#xff0c;支持100% Adobe RGB色域显示&#xff0c;拥有600尼特亮度&#xff0c;满足创意族群对色彩的精确要求。HP ZBook 15u G6图…

ORM多表操作之多对多查询

创建多对多的关系 authormodels.ManyToManyFleld(" ")  (推荐) 书籍对象它的所有关联作者 book_obj.authors.all() 掌握&#xff1a;通过filter values(双下划线)进行多对多的关联查询&#xff08;形式一对多&#xff09; django是将python语句翻译成sql语句执行 聚…

计算机的iscsi配置,我们将了解如何设置自己的支持iscsi配置的存储节点

iSCSI代表Internet小型计算机系统接口。它用于使用块级数据传输通过TCP / IP访问网络上的存储。NFS与iSCSI之间通常存在比较。关键区别在于NFS是文件级实现&#xff0c;而iSCSI是块级实现。这适用于TCP / IP层&#xff0c;并允许通过局域网(LAN)发送SCSI命令。在诸如iSCSI和光纤…

计算机基础知识复习资料,计算机基础知识复习资料

.Word 资料Ch1 计算机基础知识1.1 现代信息技术1.1.1、特征&#xff1a;●以数字技术和电子技术为基础&#xff1b;●以计算机及其软件为核心&#xff1b;●采用电子技术(包括激光技术)进行信息的收集、传递、加工、存储、显示与控制。1.1.2、数字技术一、二进位数字——比特(b…

linux上安装Docker

Docker的三大核心概念&#xff1a;镜像、容器、仓库 镜像&#xff1a;类似虚拟机的镜像、用俗话说就是安装文件 容器&#xff1a;类似一个轻量级的沙箱&#xff0c;容器是从镜像创建应用运行实例&#xff0c;可以将其启动、开始、停止、删除、而这些容器都是相互隔离、互不可见…

幼儿园计算机教师论文,幼儿园中班教师论文

幼儿园中班教师论文导语&#xff1a;幼儿教育是什么&#xff1f;幼儿应如何正确接受教育&#xff1f;幼儿接受与不接受教育区别又如 何&#xff1f;这些看似简单的问题&#xff0c;其实不然。在当今科技发达的今天&#xff0c;幼儿的教育起着承前启后的重要阶段&#xff0c;不仅…

ASP.NET 5 入门 (2) – 自定义配置

原文:ASP.NET 5 入门 (2) – 自定义配置ASP.NET 5 入门 (2) – 自定义配置 ASP.NET 5 理解和入门 建立和开发ASP.NET 5 项目 初步理解ASP.NET5的配置 正如我的第一篇文章ASP.NET 5 (vNext) 理解和概述 所说,ASP.NET 5的具有全新的配置机制,我们可以通过以下几点来进行理解: 支持…

中有冒号 文件路径_用Matlab脚本文件实现Excel文件的合并

日常吐槽前段时间跟同事聊天&#xff0c;同事洗脑了一个新的&#xff08;扎心的&#xff09;世界观&#xff0c;“人生分三个阶段&#xff0c;20岁时承认父母很平庸&#xff0c;30岁时承认自己很平庸&#xff0c;40岁时承认孩子很平庸”。这是这位同事在孩子学而思考试后的心得…

ios系统gps测试软件,GPS工具箱苹果版

GPS工具箱为用户准备的以手机GPS为基础的多功能位置服务的软件&#xff0c;它包括了很多非常实用的工具&#xff0c;包含线路追踪、测速、位置记录、面积测量等等&#xff0c;是GPS模块的功能发挥的非常充分到位&#xff0c;并且软件支持离线map和KML、KMZ导入导出、GPX文件导出…

用统计功能计算机计算js,js实现计算器功能

本文实例为大家分享了js实现计算器功能的具体代码&#xff0c;供大家参考&#xff0c;具体内容如下知识点eval() 函数可计算某个字符串&#xff0c;并执行其中的的 JavaScript 代码。代码如下js计算器计算器.h1{position: relative;color:blueviolet;font-size:50px;text-align…

方法的覆盖

【转载】类继承时方法覆盖问题。&#xff08;static方法不能被”覆盖“&#xff09; (2014-04-03 17:51:17) 转载▼在看TIJ4的144页说&#xff1a; “覆盖”只有在某方法是基类的接口的一部分时才会出现。 即&#xff0c;必须能将一个对象向上转型为他的基本类型并调用相同的方…

恋与制作人 服务器错误,恋与制作人安装失败怎么办_恋与制作人安装失败解决方法_游戏吧...

恋与制作人是一款受到了很多玩家喜爱的游戏&#xff0c;游戏是奇迹暖暖原班人马打造的&#xff0c;但是却有玩家在安装时出现了安装失败的问题。下面游戏吧小编就为各位各位玩家带来了恋与制作人的安装失败解决方法。安装失败解决方法一、 安装软件时提示应用程序未安装或者提示…

create-react-app创建项目后运行npm run eject命令报错解决办法

用create-react-app创建项目,因要配置各种组件&#xff0c;比如babel&#xff0c;antd等&#xff0c; 需要运行npm run eject命令把项目的配置文件暴露出来&#xff0c;但是还是一如既然碰到报错&#xff0c;因为是在本地新创建的文件&#xff0c;没有添加git记录 解决办法&…

wps的计算机在哪里设置密码,wps文件怎么设置和取消密码 wps文件密码设置和取消的步骤方法...

在wps制作文件时&#xff0c;有时候我们需要中途离开&#xff0c;但是担心其他用户不小心操作导致文件丢失&#xff0c;那么我们可以设置wps文件密码&#xff0c;这样就可以避免了&#xff0c;那么怎么将wps文件设置密码呢?其实设置方法非常简单&#xff0c;下面教程之家网为大…