精华内容
下载资源
问答
  • 最近换了一台电脑,需要重新配置很软件,尤其是这个vscode,实在是不想再一点点的安装那些插件,所以就在网上搜一下解决方案,具体的是使用sync这个插件,具体使用教程可以参考:参考教程,这是官方给的一个教程...

    最近换了一台新电脑,需要重新配置很多软件,尤其是这个vscode,实在是不想再一点点的安装那些插件,所以就在网上搜一下解决方案,具体的是使用sync这个插件,具体使用教程可以参考:参考教程,这是官方给的一个教程,上面还有视频,如果看了文字不是特别理解的话,还可以看一下视频教程。
    但是上面的教程只是在你不出错的情况下,但是,我配置的过程中还是出了一些小小的问题,这里总结一下:
    最主要的一个问题就是:当我在我的新电脑上下载配置的时候,它提示我那个gistid无效,我刚开始的解决办法是,重新生成一个id,然后再操作一遍,虽然这样确实可以,但是这个我感觉很不麻烦,后来在网上找到了另外一种方法:
    打开 C:\Users<你的用户名>\AppData\Roaming\Code\User\syncLocalSettings.json 。搜索token即可找到token。然后把里面的id给复制下来就可以了。
    需要注意一点的是,这个打开的不是你的新电脑,而是你上传你vscode配置的电脑。
    还有一个方法是我无意间观察到的,就是我们打开我们旧电脑上的vscode,然后重新执行一遍上传,然后在下面的命令上输出窗口,用鼠标向上滑,就可以看到很多配置信息,其中有一个GitHub Gist,后面跟了一串数字,这就是我们要找的那个id,把它给复制到新电脑上进行操作就可以了。
    到这里就圆满成功了!

    展开全文
  • 游戏后台状态同步与帧同步

    千次阅读 2017-11-11 23:19:12
    最近开始学习一下游戏后台的一些知识,一直很好奇个玩家之间的数据是如何同步的,查了一下,目前使用的比较的是状态同步和帧同步。状态同步同步的是游戏中的各种状态。一般的流程是客户端上传操作到服务器,...

    最近开始学习一下游戏后台的一些知识,一直很好奇多个玩家之间的数据是如何同步的,查了一下,目前使用的比较多的是状态同步和帧同步。

    状态同步

    同步的是游戏中的各种状态。

    一般的流程是客户端上传操作到服务器,服务器收到后计算游戏行为的结果,即技能逻辑,战斗计算都由服务器运算,然后以广播的方式下发游戏中各种状态,客户端收到状态后,更新自己本地的动作状态、Buff状态,位置等就可以了,但是为了给玩家好的体验,减少同步的数据量,客户端也会做很多的本地运算,减少服务器同步的频率以及数据量,该方式多用于回合制的游戏。

    状态同步其实是一种不严谨的同步。它的思想中,不同玩家屏幕上的表现的一致性并不是重要指标, 只要每次操作的结果相同即可。所以状态同步对网络延迟的要求并不高。像玩RPG游戏,200-300ms的延迟也可以接受。 但是在RTS游戏中,50ms的延迟却会很受伤。

    举个移动的例子,在状态同步中, 客户端甲上操作要求从A点移动到B点,但在客户端乙上, 甲对象从A移动到C,然后从C点移动到了B。这是因为, 客户端乙收到A的移动状态时, 已经经过了一个延迟。这个过程中,需要客户端乙本地做一些平滑的处理,最终达到移动到B点的结果。(可通过增加动作前后摇来减少延迟——多播一点动画,给服务器多争取一些时间!)

    帧同步

    RTS(即时战略游戏)游戏常采用的一种同步技术 ,上一种状态同步方式数据量会随着需要同步的单位数量增长,对于RTS游戏来讲动不动就是几百个的单位可以被操作,如果这些都需要同步的话,数据量是不能被接受的,所以帧同步不同步状态,只同步操作。

    1、帧率

    大家小时候应该看过这样的小人书:快速翻看就可以看到漫画上的人物会动起来。

    由于人类眼睛的特殊生理结构,如果所看画面之帧率高于每秒约10-12帧的时候,就会认为是连贯的, 此现象称之为视觉暂留。

    游戏中的所有动画也是采用这种方式来渲染,只不过帧率是由GPU来控制,你所看到的画面都是由GPU一帧帧渲染的,比如30帧/s,你所看到的画面就比较流畅了,帧率越高你所看到的越流畅。

    2、Lockstep——帧同步

    帧同步可以说是通过帧率延伸过来的,你可以把一个游戏看成一个巨大的状态机,所有的参与者都采用同一个逻辑帧率来不断的向前推进。

    • 我们把游戏的前进分为一帧帧,这里的帧和游戏的渲染帧率并不是一个,只是借鉴了帧的概念,自定义的帧,我们称为turn。游戏的过程就是每一个turn不断向前推进,每一个玩家的turn推进速度一致。

    • 每一帧只有当服务器集齐了所有玩家的操作指令,也就是输入确定了之后,才可以进行广播(并不计算游戏行为),进入下一个turn,否则就要等待最慢的玩家,如此才能保证帧一致。

    • Lockstep的游戏是严格按照turn向前推进的,如果有人延迟比较高,其他玩家必须等待该玩家跟上之后再继续计算,不存在某个玩家领先或落后其他玩家若干个turn的情况。使用Lockstep同步机制的游戏中,每个玩家的延迟都等于延迟最高的那个人。

    • 由于大家的turn一致,以及输入固定,所以每一步所有客户端的计算结果都一致的。

    这种囚徒模式的帧同步,因为某个玩家有延迟,而导致该帧的同步时间发生延迟,从而导致所有玩家都在等待,出现卡顿现象。

    3、乐观锁&断线重连

    囚徒模式的帧同步,有一个致命的缺陷就是,若联网的玩家有一个网速慢了,势必会影响其他玩家的体验,因为服务器要等待所有输入达到之后再同步到所有的c端。另外如果中途有人掉线了,游戏就会无法继续或者掉线玩家无法重连,因为在严格的帧同步的情况下,中途加入游戏从技术上来讲是非常困难的。因为你重新进来之后,你的初始状态和大家不一致,而且你的状态信息都是丢失状态的,比如,你的等级,随机种子,角色的属性信息等。

    为了解决这个问题,服务器可保存玩家当场游戏的游戏指令以及状态信息,在玩家断线重连的时候,能够恢复到断线前的状态。不过这个还是无法解决帧同步的问题,因为严格的帧同步,是要等到所有玩家都输入之后,再去通知广播client更新,如果A服务器一直没有输入同步过来,大家是要等着的,那么如何解决这个问题?

    采用“定时不等待”的乐观方式,在每次Interval时钟发生时固定将操作广播给所有用户,不依赖具体每个玩家是否有操作更新。如此帧率的时钟由服务器控制,当客户端有操作的时候及时的发送服务器,然后服务端每秒钟20-50次向所有客户端发送更新消息。(如果没有操作, 也要广播空指令来驱动游戏帧前进)

    在这种情况下,服务器不会等到搜集完所有用户输入再进行下一帧,而是按照固定频率来同步玩家的输入信息到每一个c端,如果有玩家网络延迟,服务器的帧步进是不会等待的,网速慢的玩家不会卡到快的玩家,只会感觉自己操作延迟而已。

    4、技能同步

    游戏中有很多是和概率相关的,比如说技能的伤害有一定概率的暴击伤害或者折光被击等。按照帧同步的话,基于相同的输入,每个玩家的client都是独立计算伤害的,那么如何保证所有电脑的暴击伤害一致呢。这个时候就需要用到伪随机了。

    大部分编程语言内置库里的随机数都是利用线性同余发生器产生的,如果不指定随机种子(Random Seed),默认以当前系统时间戳作为随机种子。一旦指定了随机种子,那么产生的随机数序列就是确定的。就是说两台电脑采用相同的随机种子,第N次随机的结果是一致的。

    所以在游戏开始前,服务器为每个玩家分配一个随机种子,然后同步给client,如此每个client在计算每个角色的技能时候,就能保证伤害是一致的。

    帧同步的特性导致客户端的逻辑实现和表现实现必须完全分离。Unity中的一些方法接口(如 Invoke, Update、动画系统等)是不可靠的,所有要自己实现一套物理引擎、数学库,做到逻辑和表现分离。 这样即使Unity的渲染是不同步的,但是逻辑跑出来是同步的。

    王者荣耀网络同步方案分享

    霸三国(端游):

    采用Client-Server模式,服务器做判定,客户端纯表现。

    ​好处:

    • 安全,因为都是服务器计算,客户端只负责表现层的功能,不会影响各种判断的结果。
    • C端可以预表现,根据S的结果做修正、拉扯,抖动平滑,还允许丢包快速恢复。

    缺点:

    • 需要同步的流量非常高(约等于本身的一个录像),后续加入东西需要传的内容会越来越多。(MOBA单位多,同步状态多,流量大)。
    • CS开发关联耦合多,联调、周期长
    • 同等网络条件下,CS配合高频度表现同步困难(客户端表现与服务端判定的完美匹配困难)

    王者荣耀:

    采用帧同步,对网络要求苛刻,下发的执行序列不允许丢包,如果中间出现丢包,需要等待丢的包重新到达后才能顺序后续执行。

    技术要点:
    lockstep是一种基于相同的初始状态,相同的输入,相同的处理逻辑,最终有相同的输出的同步方式。每个客户端开始load相同的数据,然后等待同步信号的驱动,每一个step到来之后,才能驱动推进一帧的update,帧间隔(delta)是相同的,如果没收到期望的那一个step驱动则需要挂起逻辑。每个客户端上报自己的输入参数,服务器按固定间隔将输入收集起来,带上step编号广播给所有客户端。

    • 相同的初始状态(不受画质,本地顺序和状态影响)
    • 完全一致的输入驱动内容及顺序
    • ​完全一直的代码执行流程(模块调用顺序,容器顺序)
    • 完全一致的随机数生成规则,一致的调用时机及次数
    • 尽量用整数实现各种游戏系统和基础数学库(浮点数运算有精度问题)
    • 避免本地逻辑(比如获取本地的时钟参数之类的,即要保证使用的参数都是所有客户端都一致的)

    整体的网络结构,分三层:服务器、客户端逻辑层、客户端表现层。

    服务器主要负责的功能有两部分:一是收集所有玩家上行的输入,把它按定时的间隔打包成输入的序列,投放给所有客户端;二是当客户端出现丢包的时候,服务器进行补发;还有把客户端上行冗余的信息替换掉,比如有新的输入到了,就把老的输入Drop或者替换掉。王者我们的逻辑是66毫秒一次,1秒同步15个包,这是不能少的,因为帧同步不能丢包,数据包必须有严格的执行序列。

    客户逻辑层理解为客户端本地的服务,就是所有客户端运行的结果必须强一致,不能有真的随机,不能有本地逻辑,不能有浮点数的运算,拿到相同的输入,产生结果必须一致。

    客户端表现层是根据逻辑层的数据去做Copy或者镜像,然后在表现层进行平滑,帧数不一样,但是不会影响最终的运算结果,只影响动画和动作的表现。

    TCP技术当外网出现丢包或者抖动的时候,受限于实现方式,比如窗口、慢启动各方面的原因,会发现当出现重联的时候会非常卡,所以PVP没有用TCP,改为了采用udp。如果出现丢包,服务器会在应用层做补发。客户端不需要做反向ACK,因为帧是有序的,如果客户端收到一个1,然后收到一个3,那么肯定2丢失了,然后就请求服务器重发。
    上行和下行都会有冗余,对于客户端消息上行的冗余,当客户端放了2个技能,收到回包之后,发现没有消息来抵消刚刚的操作,则会进行补发。对于下行的冗余,每一帧都会带上至少3帧的数据(会根据具体情况浮动),这样子如果最近3帧数据有丢包,则客户端直接可用,不需要再重发,减少延迟。冗余尽量放在同一个mtu里面。udp受限于mtu的大小,大于mtu,会出现分包,可能也会出现整包的丢失。所以我们也会有些比较大的包会在应用层由服务器做分包,中间出现丢包再由服务器补发,把零碎的包拼成整包再做解包。

    帧同步的消息比较小,按照理论1秒15个驱动帧来算,20分钟的录像是10M左右。但是我们外网统计,正常的5V5对局20分钟,录像的大小大概是3M左右。服务器会把玩家的操作做纯内存的存储,当出现丢包的时候,服务器会通过编号快速找到缓存信息进行下发(可靠UDP下发)。同时根据丢包的情况,我们会计算给这个人发送冗余量的变化量。最开始发送每个包会冗余前面3帧的信息,如果丢包严重,我们会尝试冗余更多信息再下发。客户端拿到之后会尽量压缩逻辑执行的过程。帧同步有比较麻烦的模式在于,它不像CLIENT-SERVER的模式随进随出,崩溃之后重回必须从一开始运行,中间运算过程不能少掉。

    一些尝试最后放弃:

    • 客户端上行之后,不需要服务器定时的间隔去做收集然后下发,而是通过染色帧编号直接下发,这样响应更及时,操作反馈更强、更快。当时我们做出来的结果是,这对手感的提升微乎其微,但是带来的负面问题却很大,因为不再是一秒15个包固定的下发,下发包的数量非常多,完全和这个人的操作习惯有关系,有可能一个人一秒之内产生了十几二十个输入,就需要把这些输入打包之后对客户端下发。客户端因为收包很多,设备也会明显发烫。

    • 传统的帧同步的方式会做延迟投递,这个我们也有尝试过。如果间隔时间内出现丢包,或者出现包下行的时网络波动,可以通过延迟投递这种方式抹平抖动和丢包的情况。我们尝试过这个方案但最终没有这样做的原因在于:《王者荣耀》里面一些英雄体验起来感觉偏动作,对反应要求比较快,延迟投递虽然抗抖动和抗丢包的能力确实不错,但是手感上达不到我们的要求。

    • 做CLIENT-SERVER方式的实现,一般都会有一个套路,客户端提前表现,根据服务器的表现做平滑或者拉扯。这个方案我们也尝试过,但最终还是放弃了,因为这个技术会让角色本身的表现有点发飘。客户端本地动,马上客户端表现就跟着动,但根据服务器的下行,其实会做一些偏移或者修正。当网络抖动出现的时候,角色会有一点发飘,所以这个方案我们放弃掉了。(目前的预表现是大概20帧,一秒多,之后如果没有收到包会卡住不动了)

    • 帧同步方案,所有客户端进行运算,期望产生一致的结果,但如果因为bug或者某个人使用修改器,跑出来的结果会和其他人不一样,当不一样出现,我们的说法是不同步了。我们会定时把一些关键信息提取出来做hash,不同步的人的hash和其他人会不一样。这是时候把这个人踢掉重连。

    安全方面:

    • 举手表决:出现不一致时,多数结果者定为最终结果。
    • 对于争议局:客户端记录关键信息,结算时传给服务器,服务器做判定记录,看看哪个异常点多,多者无效。
    展开全文
  • C# 线程同步

    千次阅读 2010-06-12 15:47:00
    C# 线程同步

    C#中的多线程

    尔定律已经改变了。计算机仍然在不断变快,不过所倚赖的不再是时钟频率,而是更多的处理器核心。大势所趋,开发者也愈发频繁地处理多线程编程的任务。

    多线程编程更加困难,且很容易出错。很多难以察觉的bug都出现在线程的切换过程中。除非仔细检查程序中的每一行代码,并分析线程切换时的各种情况,否则很容易引入潜在的问题。也许有一天,线程的切换发生在了你测试时没有覆盖到的地方,从而导致了程序的崩溃。这样,编写正确的程序变得更加困难,验证程序正确性的难度也大大提高。因此,虽然多线程程序更为流行了,但开发难度仍旧要比单线程程序大得多。

    本章并不会让你成为多线程编程的专家,不过其中给出的条目却都是.NET多线程程序设计中的一些常见的建议和规则。若想完整地学习多线程技术的方方面面,我推荐阅读Joe Duffy的Concurrent Programming on Windows Vista: Architecture, Principles, and Patterns(Addison-Wesley, 2008)。在取得以上共识后,下面我们先来看看多线程编程相对于单线程编程所增加的挑战。

    简单地将单线程程序转为并行执行将导致很多问题。我们来看一个简单的银行账户的定义。

    public class BankAccount

    {

        public string AccountNumber

        {

            get;

            private set;

        }

        public decimal Balance

        {

            get;

            private set;

        }

        public BankAccount(string accountNumber)

        {

            AccountNumber = accountNumber;

        }

        public void MakeDeposit(decimal amount)

        {

            Balance += amount;

        }

        public decimal MakeWithdrawal(decimal amount)

        {

            if (Balance > amount)

            {

                Balance -= amount;

                return amount;

            }

            return 0M;

        }

    }

    如此简单的一段代码,无需过多检查即可保证其正确性。不过在多线程环境中却并非如此。为什么呢?因为上述代码中包含了很多潜在的竞争条件。存款和取款的方法实际上均由几个不同的操作组成。+=操作符首先将当前的账户余额从内存中读取出来并保存于寄存器中。随后CPU将执行相加操作。随后,新的余额才会被写回至内存中。

    问题在于,在多核心的处理器上,应用程序中的多个线程可能同时运行于多个核心上。这样,不同的线程就有可能交替地对同一块内存地址进行读写,进而造成数据错误。考虑如下的场景。

    (1) 线程A开始存入10 000美元的操作。

    (2) 线程A获取到当前的余额为2 000美元。

    第2章 C#中的多线程

     
    (3) 线程B开始存入4 000美元的操作。

    (4) 线程B获取到当前的余额为2 000美元。

    (5) 线程B计算出新的余额为6 000美元。

    (6) 线程A计算出新的余额为12 000美元。

    (7) 线程A将余额12 000美元保存。

    (8) 线程B将余额6 000美元保存。

    这样,这种线程之间的交替操作就导致了从前单线程情况下不会发生的错误。

    之所以会出现这样的竞争条件,是因为该类中没有为可能引起副作用的操作提供任何同步机制。Deposit()和Withdrawal()方法都会产生副作用:它们均修改了当前的状态,并返回新的值。这两个方法依赖于调用方法时系统的当前状态。例如,若是账户中的余额不足,那么取款操作将失败。没有副作用的方法一般均不需要过多的同步。此类方法不依赖于当前状态,因此在方法执行过程中,即使当前状态发生了改变,也不会影响到其执行结果。

    修复这个问题非常简单,只需添加一些锁定即可。

    public void MakeDeposit(decimal amount)

    {

        lock (syncHandle)

        {

            Balance += amount;

        }

    }

    public decimal MakeWithdrawal(decimal amount)

    {

        lock (syncHandle)

        {

            if (Balance > amount)

            {

                Balance -= amount;

                return amount;

            }

        }

        return 0M;

    }

    这样似乎解决了问题,不过却可能导致死锁。例如,若某个客户拥有两个银行账户,一个储蓄账户,一个支票账户。客户可能需要进行转账操作——从一个账户中提款,并存入另一个账户中。逻辑上,这是个单一的操作。不过在实现上,它却包含了一系列的操作。首先从一个账户中取款(这本身也是个多步的操作),随后存入另一个账户(也是个多步操作)中。这似乎不会有什么问题:在从一个账户中取款时锁定该账户,然后获取第二个账户的锁定,并执行存款操作。

    不过若是多个线程在同时执行转账操作,那么就可能发生死锁。死锁将会发生的情况是,两个线程互相持有另一个线程完成工作所需要的锁。这样,无论等待多长时间,情况都不会有所转变。应用程序看起来就像是崩溃了。实际上并没有崩溃,不过却在等待着一些永远都不会发生的事情。

    另一种比死锁略微好一点的情况是活锁。活锁涉及一种较为复杂的锁定机制,这种机制把对同一块数据的读取和写入区分对待。这种机制允许让多个读取线程同时检查一块数据,不过同时仅允许一个写入线程修改该数据。此外,当某一写入线程在修改数据时,也不允许读取线程读取该数据。活锁将发生于这样的情况下:不停有读取线程在检查某块数据,而让写入线程无法插入到其中。这样,该数据实际上就变为了只读的。

    没有什么好的方法可以避免此类问题。多线程编程本身就很复杂,所有操作的复杂性也有了很大的提高。不过多线程编程前景广阔,因此每个C#开发人员至少都需要对多线程技术有一些基本的了解。

    .NET Framework在很多地方都使用到了多线程。例如在Web应用程序和Web服务中,每个请求都由一个专门的ASP.NET工作线程负责。remoting类库也用同样的方式处理每个请求。有些计时器的事件处理程序运行于新的线程之上。WCF(Windows Communication Foundation)类库也使用了多个线程。你还可以在调用Web服务时采用异步的方式。

    你总会用到上述这些技术。因此若想对.NET有更深入的理解,那么必须了解一些多线程技术。

    条目11:使用线程池而不是创建线程

    你无法知道应用程序中需要的线程的最佳数量。你的应用程序可能运行于拥有多个核心的计算机上,不过无论你今天假设将会有多少个核,6个月后都十有八九会不正确。此外,你也无法控制CLR为完成其自身工作(例如垃圾收集器等)所创建的线程的数量。在服务器应用程序中,例如ASP.NET或WCF服务,每个请求都由一个不同的线程处理。作为应用程序或类库开发者的我们,将很难优化目标系统上的线程数量。但.NET线程池却能够获取到所有的必要信息,来优化指定系统上活动线程的数量。此外,即使你创建了过多的任务或线程,那么线程池也能用队列把无法及时处理的请求保存起来,直至有线程释放出来。

    .NET线程池能够替你完成很多线程资源的管理工作。当应用程序开始执行重复的后台任务,且并不需要经常与这些任务交互时,使用.NET线程池管理这些资源将会让程序的性能更佳。

    调用QueueUserWorkItem方法即可让线程池来为你管理资源。在其中添加项目之后,该项目将在有空余线程时得以执行。根据正在运行的任务的数量和线程池的大小,项目可能会立即执行,或等待直至有空余的线程出现。线程池由每个处理器中一定数量的就绪线程和一系列I/O读取线程组成。具体的数字因版本的不同而不同。在开始向队列中插入新的任务时,线程池可能会创建更多的线程,这也取决于当前内存以及其他资源的可用情况。

    这里并没有仔细解释线程池的实现,因为线程池本身是用来减轻我们的工作,并让框架去帮我们分担的。简而言之,线程池中的线程数量将在可用线程数量和最小化已分配但尚未使用的资源之间自动平衡。在添加了工作项之后,若当前还有可用线程,那么就会开始执行。线程池的工作是保证尽可能快地提供出可用线程。不过对于你来说,只要发起请求,就不必再担心其内部处理机制了。

    线程池同样也会自动管理任务结束后的维护工作。当任务结束之后,线程并不会被销毁,而是返回到可用状态,以便执行其他任务。稍后,该线程可能会被线程池分配去处理另外的任务。接下来的任务并不一定和前面的任务一样,可能为应用程序需要的任何一个较耗时的方法。只需调用QueueUserWorkItem并传入需要的方法,线程池将自动为你管理好所有的一切。

    线程池还能够用另一种方法帮你管理运行于其他线程中的任务。所有QueueUserWorkItem使用的线程池中的线程均为后台线程(background thread)。这也就意味着你并不需要在应用程序退出之前手工清理资源。若是应用程序在这些后台线程仍在运行时就退出了,那么系统将停止这些后台任务,并释放所有与应用程序相关的资源。你只需要在退出应用程序之前确保停止了所有非后台线程即可。不过若没有做到这一点,那么应用程序很可能在不做任何事情的时候还占用着资源。

    从另一个角度考虑,因为后台线程将在没有任何警告的情况下被终止,因此需要小心其中对系统资源的访问方式,免得当应用程序终止时,让系统停留在不稳定的状态中。很多情况下,当线程终止时,运行时将在该线程上抛出ThreadAbortException异常。而若是应用程序在尚有后台线程运行时突然终止,那么后台线程将不会收到任何通知,而只是被立即终止。因此,若某个线程可能会让系统资源处于不稳定状态中,那么则不要使用后台线程。好在这种情况并不多见。

    系统将管理线程池中活动线程的数量。线程池将根据当前可用的系统资源数量来适当地执行任务。若系统负荷已经接近极限,那么线程池将暂停开启新任务。而若是系统并不繁忙,那么线程池将立即开始新任务。我们无需手工编写负载均衡的逻辑,线程池将为你做好这一切。

    或许你会认为,同时执行的任务的最佳数量就等于计算机上的CPU内核数量。这虽然不算是个最差的策略,不过却过于简单了一些,基本上也不会是最好的答案。等待时间、对除了CPU之外其他资源的争夺以及其他你无法控制的线程均会影响到应用程序中最佳的线程数量。若创建的线程过少,那么将无法完全利用到系统资源,白白浪费了计算能力。而若是线程数量过多,那么计算机将花费过多的时间在线程调度上,进而影响到线程的实际执行时间,反倒降低了整体效率。

    为了给出一些通用的原则,我编写了一个使用“亚历山大港的希罗”(Hero of Alexandria)提出的海伦算法计算平方根的小程序。这些原则均比较通用,因为每种算法都有它自己的特性。在这个程序中,核心算法比较简单,且并不需要与其他线程进行交互。

    算法的开始将作一次猜测,取得某个数字的平方根,比如猜测1。若想找到下一个近似值,只需计算出当前猜测和原输入与当前猜测的商的平均值。例如,若想得到10的平方根,首先猜测为1,接下来的猜测为[1 + (10/1)]/2,即5.5。重复上述步骤,直至得到符合条件的正确值即可。其代码如下。

    public static class Hero

    {

        private const double TOLERANCE = 1.0E-8;

        public static double FindRoot(double number)

        {

            double guess = 1;

            double error = Math.Abs(guess * guess - number);

            while (error > TOLERANCE)

            {

                guess = (number / guess + guess) / 2.0;

                error = Math.Abs(guess * guess - number);

            }

            return guess;

        }

    }

    为了比较线程池、手工创建线程和单线程三种情况下应用程序的性能,这里我还给出了相应的测试程序,其中将重复多次进行该计算。

    private static double OneThread()

    {

        Stopwatch start = new Stopwatch();

        start.Start();

        for (int i = LowerBound; i < UpperBound; i++)

        {

            double answer = Hero.FindRoot(i);

        }

        start.Stop();

        return start.ElapsedMilliseconds;

    }

    private static double ThreadPoolThreads(int numThreads)

    {

        Stopwatch start = new Stopwatch();

        using (AutoResetEvent e = new AutoResetEvent(false))

        {

            int workerThreads = numThreads;

            start.Start();

            for (int thread = 0; thread < numThreads; thread++ )

                System.Threading.ThreadPool.QueueUserWorkItem(

                    (x) =>

                    {

                        for (int i = LowerBound;

                            i < UpperBound; i++)

                        {

                            // 进行计算

                            if (i % numThreads == thread)

                            {

                                double answer = Hero.FindRoot(i);

                            }

                        }

                        // 减少计数器的值

                        if (Interlocked.Decrement(

                            ref workerThreads) == 0)

                        {

                            // 设置事件

                            e.Set();

                        }

                    });

            // 等待信号

            e.WaitOne();

            // 跳出

            start.Stop();

            return start.ElapsedMilliseconds;

        }

    }

    private static double ManualThreads(int numThreads)

    {

        Stopwatch start = new Stopwatch();

        using (AutoResetEvent e = new AutoResetEvent(false))

        {

            int workerThreads = numThreads;

            start.Start();

            for (int thread = 0; thread < numThreads; thread++)

            {

                System.Threading.Thread t = new Thread(

                    () =>

                    {

                        for (int i = LowerBound;

                            i < UpperBound; i++)

                        {

                            // 进行计算

                            if (i % numThreads == thread)

                            {

                                double answer = Hero.FindRoot(i);

                            }

                        }

                        // 减少计数器的值

                        if (Interlocked.Decrement(

                            ref workerThreads) == 0)

                        {

                            // 设置事件

                            e.Set();

                        }

                    });

                t.Start();

            }

            // 等待信号

            e.WaitOne();

            // 跳出

            start.Stop();

            return start.ElapsedMilliseconds;

        }

    }

    单线程版本非常简单。而两个多线程版本均使用了lambda表达式语法(参考第1章条目6)来定义后台线程将要执行的操作。当然,如条目6中所述,也可以将lambda表达式替换成匿名委托。

    System.Threading.ThreadPool.QueueUserWorkItem(

        delegate(object x)

        {

            for (int i = LowerBound; i < UpperBound; i++)

            {

                // 进行计算

                if (i % numThreads == thread)

                {

                    double answer = Hero.FindRoot(i);

                }

            }

            // 减少计数器的值

            if (Interlocked.Decrement(

                ref workerThreads) == 0)

            {

                // 设置事件

                e.Set();

            }

        });

    若使用显式的方法并显式创建委托的话,需要增加很多代码。因为外部方法中定义的很多局部变量(重置事件、线程数和当前线程的索引)都会被用于内部的后台线程中。而C#编译器则会自动为使用了lambda表达式的内联方法创建一个闭包(参见第4章条目33和第5章条目41),省去了我们的工作。此外,注意labmda表达式语法也可以用于多语句的方法,而不仅限于单一的表达式中。

    主程序为三个版本的算法均统计了时间,这样可以看到使用线程给算法带来的影响。图2-1即为统计结果。从该示例中,我们可以学到一些东西。首先,与线程池线程相比,手工创建线程的开销更大。若创建了10个以上的线程,那么过多的线程将成为最主要的性能瓶颈。即使在这个并不需要太多等待时间的算法中,其影响也显而易见。

    每100 000次

    计算所用的
    时间(ms)

     

    线程数量(多线程算法)

     

    手工线程

     

    线程池

     

    单一线程

     
        

    图2-1 单线程、使用System.Threading.Thread和使用System.Threa- ding.ThreadPool.QueueUserWorkItem程序的计算时间结果。y轴为在一台双核笔记本电脑上执行100 000次计算所花费的时间(单位为ms)

    若使用线程池,那么必须添加40个以上的项目,才会看到额外开销逐渐占据了支配地位。而这只是在一台双核的笔记本电脑上而已。对于那些服务器级别的计算机,更多的核心将支持更多的并发线程。让线程数量多于内核数量通常将带来更好的结果。不过,其实际效果非常依赖于应用程序本身,还依赖于应用程序线程花费在等待资源上的时间。

    之所以线程池实现要优于手工创建线程,主要有两个因素。首先,线程池将重用那些被释放了的线程。而在手工创建线程时,必须为每个任务创建一个全新的线程。线程的创建和销毁所花费的时间要高于.NET线程池管理所带来的开销。

    第二,线程池将为你管理活动线程的数量。若创建了过多的线程,那么系统将挂起一部分,直到有足够的资源执行。QueueUserWorkItem则将工作交给线程池中接下来的一个可用线程,并帮你完成一定的线程管理工作。若应用程序线程池中所有的线程均被占用,那么线程池也会挂起任务,直至出现可用线程。

    随着多核处理器的一天天普及,你会越来越频繁遇到多线程的应用程序。若你在开发.NET服务器端应用程序,例如WCF、ASP.NET或.NET远程处理,那么则已经开始与多线程打交道了。这些.NET子系统均使用线程池来管理线程,因此你也应该采用同样的做法。线程池能够降低额外开销,进而提高性能。此外,.NET线程池也能够帮你更好地管理当前用于执行工作的活动线程数量。

     

    条目12:使用BackgroundWorker实现线程间通信

    条目11演示了使用ThreadPool.QueueUserWorkItem来执行多个后台任务。该API非常易于使用,因为大多数的线程管理工作都交给了框架和底层的操作系统来完成。其中的很多功能也能够重用,因此若在应用程序中需要使用到后台线程来执行任务的话,那么QueueUserWorkItem将是你首要考虑使用的工具。但对于将要执行的后台任务,QueueUserWorkItem有着几个假设。若是程序的实际需求不符合这些假设,那么还是需要额外的工作。不过不是直接使用System.Threading.Thread来创建线程,而是使用System.Compon- entModel.BackgroundWorker。BackgroundWorker不仅构造于ThreadPool之上,还为线程间通信提供了很多支持。

    其中需要处理的最重要的问题就是在WaitCallback中抛出的异常。WaitCallback即为实际执行任务的后台线程,若是该方法中有任何异常抛出,那么系统将终止应用程序,而不仅仅是终止该后台线程。这个行为和其他后台线程API的行为一致,不过处理的难点在于,QueueUserWorkItem并没有提供任何内建的错误处理机制。

    此外,QueueUserWorkItem也没有提供实现后台线程和前台线程之间交互的内建支持——你不能检查完成情况、跟踪进度、暂停任务或是取消任务。若程序需要这些功能,那么则要使用建立在QueueUserWorkItem功能之上的BackgroundWorker组件。

    BackgroundWorker组件包含了System.ComponentModel.Component的功能,用来提供设计时的支持。不过在没有设计器支持时,BackgroundWorker在代码中也非常有用。实际上,我在使用BackgroundWorker的大多数情况时,都不是在Windows窗体中。

    BackgroundWorker的最简单使用方法是创建一个符合委托签名的方法,将该方法附加到BackgroundWorker的DoWork事件上,然后调用Background- Worker的RunWorkerAsync()方法。

    BackgroundWorker backgroundWorkerExample =

        new BackgroundWorker();

    backgroundWorkerExample.DoWork += new

        DoWorkEventHandler(backgroundWorkerExample_DoWork);

    backgroundWorkerExample.RunWorkerAsync();

    // 其他位置:

    void backgroundWorkerExample_DoWork(object sender,

        DoWorkEventArgs e)

    {

        // 方法的内容省略

    }

    在这个模式中,BackgroundWorker提供的功能和ThreadPool.Queue- UserWorkItem完全一致。BackgroundWorker将使用ThreadPool执行其后台任务,其内部也会用到QueueUserWorkItem。

    BackgroundWorker的强大之处在于,其整套框架已经为一些常见的场景提供了支持。例如,BackgroundWorker使用事件在前台和后台线程之间通信。当前台线程发起一个请求时,BackgroundWorker将触发后台线程上的DoWork事件。随后DoWork事件的处理程序将读取其参数,并开始相应的执行工作。

    在后台线程任务结束(即DoWork事件处理函数执行完成)之后,Back- groundWorker将在前台线程中触发RunWorkerCompleted事件,如图2-2所示。这样,前台线程即可根据需要在后台线程结束时执行必要的后续处理。

    图2-2 BackgroundWorker类能够在后台线程执行完毕时触发前台线程中的事件处理程序。只要注册了完成事件,那么在DoWork委托完成执行之后,BackgroundWorker就会触发该事件

    除了BackgroundWorker触发的事件,也可以用属性来维护控制前台线程和后台线程间的交互。WorkerSupportsCancellation属性让Background- Worker知道后台线程是否能够中止该操作并退出。WorkerReportsProgress属性则会告知BackgroundWorker对象,每过一段时间后台线程将会通知前台线程其执行进度,如图2-3所示。此外,BackgroundWorker还能将取消请求从前台线程转发给后台线程。这样,后台线程即可检查CancellationPending标记,并根据需要停止执行。

    图2-3 BackgroundWorker支持使用多个事件来取消执行任务,向前台线程报告执行进度以及错误报告。BackgroundWorker定义了线程间通信的协议,并在需要的时候通过事件来支持通信机制。若想报告执行进度,那么后台线程必须触发定义在BackgroundWorker中的事件。前台线程的代码也必须支持此类事件,提供必要的事件处理程序

    此外,BackgroundWorker还拥有内建的协议来支持报告后台线程中发生的错误。在条目11中,我曾经解释过异常不能从某个线程抛到另一个线程中。若是后台线程中抛出了异常,且没有被捕获,那么该线程将被终止。不仅如此,前台线程也不会收到任何有关后台线程已被终止的通知。BackgroundWorker通过在DoWorkEventArgs中添加Error属性,并将异常信息保存在其中来解决了这个问题。后台线程可以捕获所有的异常,并将其设置到Error属性中。(需要注意的是,这是仅有的几个需要捕获所有异常的场景之一。)随后在后台线程返回时,前台线程即可在事件处理程序中处理该异常。

    前面曾提到过,我经常在非Form类中使用BackgroundWorker,甚至在非Windows窗体中,例如服务或Web服务等。不过有几点注意之处。当Backgro- undWorker检测到其正运行于Windows窗体程序中,且该窗体为可见,那么ProgressChanged和RunWorkerCompleted事件将通过转发控制以及Control.BeginInvoke被转发给GUI线程中(参见本章条目16)。在其他情况下,这些委托只是运行于线程池中的某个空闲线程上。在条目16中你将看到,这个行为可能会影响到接收到事件的顺序。

    最后一点,因为BackgroundWorker构建于QueueUserWorkItem之上,所以我们可以使用BackgroundWorker来处理多个后台请求。通过检查BackgroundWorker的IsBusy属性即可判断BackgroundWorker当前是否在执行任务。若需要同时执行多个后台任务,那么可以创建多个BackgroundWorker对象。这些BackgroundWorker对象均会共享同一个线程池,因此其实际的执行效果和QueueUserWorkItem一样。这样就需要保证事件处理程序要访问到正确的线程,以便保证后台线程和前台线程之间通信的正确性。

    BackgroundWorker支持创建后台任务时的很多常用模式。借助于BackgroundWorker,我们即可提高代码的重用性,根据需要使用这些模式,而并不用手工定义前后台线程之间的通信协议。

     

    条目13:让lock()作为同步的第一选择

    线程之间需要进行通信。我们需要提供一种安全的方式,让应用程序中的线程能够发送并接收数据。不过,在线程间共享数据可能会引发同步问题,导致数据完整性方面的错误。因此,必须保证每一块共享数据的当前状态都是一致的。实现该目标需要使用同步原语(synchronization primitive)来保护对同享数据的访问。同步原语能够保证在完成一系列必要操作之前,当前线程不会被打断。

    .NET BCL中提供了很多不同的同步原语,均可用来保证共享数据的同步。不过仅有一对——Monitor.Enter()和Monitor.Exit()——在C#语言上得到了原生支持。Monitor.Enter()和Monitor.Exit()共同组成了一个临界区。临界区在保证同步方面的应用非常广泛,因此语言本身就对其提供了支持——lock()语句。这样,我们应该尽可能遵循语言设计者的意愿,让lock()作为保证同步的第一选择。

    原因很简单:编译器生成的代码永远是一致的,而开发者则可能会犯错误。C#语言引入了lock关键字来控制多线程程序的同步操作。lock语句将生成与正确使用Monitor.Enter()和Monitor.Exit()同样的代码。此外,该关键字更见简单,且能够自动生成所需的可以安全处理异常的代码。

    不过,在两种情况下,Monitor也能提供两种lock()无法实现的功能。首先,lock必须使用在同一个上下文中。也就是说,在使用lock时,你无法在一个上下文中进入锁定却在另一个上下文中退出。例如,无法在某个方法中进入Monitor,然后在该方法中的某个lambda表达式中退出(参见第5章条目41)。其次,Monitor.Enter支持制定一个超时时间,这一点将在稍后介绍。

    按如下方式使用lock语句即可锁定某一引用类型。

    public int TotalValue

    {

        get

        {

            lock(syncHandle)

            {

                return total;

            }

        }

    }

    public void IncrementTotal()

    {

        lock (syncHandle)

        {

            total++;

        }

    }

    lock语句将独占地锁定某一对象,并确保在锁定被释放之前其他线程无法再次锁定。上述使用lock()的示例代码将生成与下面使用Monitor.Enter()和Monitor.Exit()代码同样的IL。

    public void IncrementTotal()

    {

        object tmpObject = syncHandle;

        System.Threading.Monitor.Enter(tmpObject);

        try

        {

            total++;

        }

        finally

        {

            System.Threading.Monitor.Exit(tmpObject);

        }

    }

    为了避免常见错误,lock语句还提供了很多检查,例如检查被锁定对象为引用类型。而Monitor.Enter则并没有包含这些检查。如下这段使用lock()的代码无法通过编译。

    public void IncrementTotal()

    {

        lock (total) // 编译期错误:无法锁定值类型

        {

            total++;

        }

    }

    不过如下代码则可以编译通过。

    public void IncrementTotal()

    {

        // 并没有真正锁定住total

        // 锁住的是total的一个装箱对象

        Monitor.Enter(total);

        try

        {

            total++;

        }

        finally

        {

            // 会抛出异常

            // 释放了包含total的另一个装箱对象

            Monitor.Exit(total);

        }

    }

    之所以Monitor.Enter()可以通过编译,是因为其签名接受的是System. Object对象,因此程序将把total装箱成对象传入。这样,Monitor.Enter()实际上锁定的是total的一个装箱对象,因此埋下了一个潜在的bug。假设第一个线程进入到了IncrementTotal()中并获取了锁定。然后在对total进行操作时,第二个线程也调用了IncrementTotal()。这时,第二个线程依旧可以成功地获取锁定,因为total可被装箱成另外一个对象。第一个线程获取了total的一个装箱,第二个线程则获取了total的另一个装箱。可以看到,这样做不但增加了代码,也没有实现保证同步所需的要求。

    这段代码中还有一个bug:在任意一个线程尝试释放total上的锁时,Mon- itor.Exit()均会抛出SynchronizationLockException异常。这是因为调用Monitor.Exit()时total又被装箱到了另外的一个新对象中,因为Monitor. Exit()接受的也是System.Object类型。在释放锁时,释放的对象和开始时锁定的对象并不是同一个,自然也就导致Monitor.Exit()调用失败并抛出异常。

    或许有人聪明一些,想出了这样的做法。

    public void IncrementTotal()

    {

        // 同样无法正常执行:

        object lockHandle = total;

        Monitor.Enter(lockHandle);

        try

        {

            total++;

        }

        finally

        {

            Monitor.Exit(lockHandle);

        }

    }

    虽然这段代码不会抛出异常,不过也不能保证共享数据的同步。每次调用IncrementTotal()时,均会为total创建一个新的装箱,并锁定该对象。这样,每个线程都能立即获取到所需要的锁,但是却没有锁定到任何共享的资源上。导致的结果就是,虽然每个线程都不会阻塞,但total却无法保持一致。

    lock还能预防一些比较易于忽视的问题。Enter()和Exit()是两个独立的调用,很容易写错,导致获取和释放的是两个不同的对象。这将导致SynchronizationLockException。而若是需要锁定多个对象,那么也很有可能在临界区结束时释放了错误的对象。

    lock语句还能够自动地生成能够安全处理异常的代码,而这些往往是开发者所忽视的。此外,它生成的代码也要比Monitor.Enter()和Monitor.Exit()更加高效,因为其只需要对目标对象进行一次求值。因此在默认情况下,我们应该在C#程序中尽可能地使用lock语句来保证同步性。

    不过,lock语句所生成的MSIL也存在着局限:Monitor.Enter()将在获取到锁之前永远等待下去。这样就可能造成死锁。在大规模的企业系统中,访问关键资源的策略应该更加小心仔细、趋于保守。这时,使用Monitor.TryEnter()即可给出一个等待的超时时间,并给出无法访问到关键资源时的处理方法。

    public void IncrementTotal()

    {

        if (!Monitor.TryEnter(syncHandle, 1000)) // 等待1s

            throw new PreciousResourceException

                ("Could not enter critical section");

        try

        {

            total++;

        }

        finally

        {

            Monitor.Exit(syncHandle);

        }

    }

    还可使用泛型类对其进行简单包装。

    public sealed class LockHolder<T> : IDisposable

        where T : class

    {

        private T handle;

        private bool holdsLock;

        public LockHolder(T handle, int milliSecondTimeout)

        {

            this.handle = handle;

            holdsLock = System.Threading.Monitor.TryEnter(

                handle, milliSecondTimeout);

        }

        public bool LockSuccessful

        {

            get { return holdsLock; }

        }

        #region IDisposable Members

        public void Dispose()

        {

            if (holdsLock)

                System.Threading.Monitor.Exit(handle);

            // 不要重复释放

            holdsLock = false;

        }

        #endregion

    }

    随后这样使用该泛型类。

    object lockHandle = new object();

    using (LockHolder<object> lockObj = new LockHolder<object>

        (lockHandle, 1000))

    {

        if (lockObj.LockSuccessful)

        {

            // 具体操作省略

        }

    }

    // 在此处析构

    之所以C#开发团队为Monitor.Enter()和Monitor.Exit()添加了语言级别的支持(即lock语句),是因为这是一种最常用的同步机制。编译器所做的额外检查也能让你更容易地编写出要求保证同步的代码。因此对于大多数C#应用程序来讲,lock()都是保证同步的最佳选择。

    不过lock并不是同步的唯一选择。实际上,若是需要同步地访问某个值类型,或替换某个引用类型,那么System.Threading.Interlocked类型即可直接支持对象上的单一操作。System.Threading.Interlocked提供了一系列方法,可用来访问共享数据,并保证在其他线程访问该数据之前就完成上一次操作。Interlocked还能帮你预防操作共享数据时将会遇到的一些常见的同步问题。

    例如如下方法:

    public void IncrementTotal()

    {

        total++;

    }

    在这样的实现中,多线程访问可能会造成数据的不一致。因为自增操作符并不是单一的一条机器指令。total变量的值需要首先从内存读入到寄存器中,然后在寄存器中自增,最后再从寄存器写回到内存中的特定位置里。若是另外一个线程在第一个线程之后再次读取该变量,此时第一个线程已经在寄存器中完成了增加但尚未写回内存,就会造成数据的不一致。

    假设两个线程几乎在同时调用了IncrementTotal。线程A读取了total的值为5。此时,活动线程切换到了线程B。于是线程B读取到了5,自增,然后把6写回到total中。这时活动线程又切换回了线程A。线程A将在寄存器中将数值自增到6,然后写回到total中。这样,虽然IncrementTotal()被调用了两次(线程A和线程B),不过结果是total仅仅自增了一次。此类问题很难发现,因为只有在非常凑巧的时候才会发生这类交叉访问的情况。

    虽然可以使用lock()来保证同步,不过还有一种更好的办法。Inter- locked类提供了一个专门的InterlockedIncrement方法来解决这个问题。按照如下方法重写IncrementTotal,即可保证自增操作不会被打断,两次自增操作均会成功。

    public void IncrementTotal()

    {

        System.Threading.Interlocked.Increment(ref total);

    }

    Interlocked类还提供了另外一些处理内建类型的方法。例如Interlo cked.Decrement()能够自减某个值,Interlocked.Exchange()能够将变量的值交换成新的值,并将原始值返回。你可以用Interlocked.Exchange()来设定一个新的状态,并将从前的状态返回。例如,若需要将最后一个访问某资源的用户ID保存起来,即可调用Interlocked.Exchange()来保存当前的用户ID,并同时获取到前一个访问的用户ID。

    Interlocked还提供了CompareExchange()方法,用来读取某个共享的数据,随后判断若其与某一值相同的话,则赋以新值,否则不作任何操作。两种情况下CompareExchange都会返回从前的值。在下一节中,条目14将演示如何使用CompareExchange来在类中创建一个私有的锁对象。

    同步原语中并不只包含Interlocked和lock()。Monitor类还提供了Pulse和Wait方法,可用来实现消费者/生产者模型。在很多线程读取某一资源,且很少线程修改该资源时,可以使用ReaderWriterLockSlim实现该设计。ReaderWriterLockSlim对早先版本的ReaderWriterLock做了一些改进,因此在开发中应该选用ReaderWriterLockSlim。

    对于大多数同步问题,都可以先看看Interlocked是否能够满足你的需要。很多单一的操作都可以用它来实现。否则应尽可能地使用lock()语句。只有在确实需要某些特定的锁实现时,再考虑使用别的方法。

     

    条目14:尽可能地减小锁对象的作用范围

    在编写并发程序时,我们需要选择最合适的同步原语。应用程序中对同步原语使用得越多,也就越难以避免发生死锁或失锁等并发上的错误。这是个规模的问题:需要检查的地方越多,也就越难发现某个特定的错误。

    在面向对象编程中,我们使用私有成员变量来尽可能减少(不是移除,而是减少)发生状态变化的位置的数量。在并发程序中,同样也应该尽可能地减小用来实现同步对象的作用范围。

    若从上述角度来看,两种广泛应用的锁方式均不满足要求。lock(this)和lock(TypeOf (MyType))都使用了公共实例来创建锁对象。

    若是你像下面这样编写代码:

    public class LockingExample

    {

        public void MyMethod()

        {

            lock (this)

            {

                // 省略

            }

        }

        // 省略

    }

    再假如你的某个客户——叫他亚历山大好了——说他需要锁住一个对象。于是亚历山大这样写了代码:

    LockingExample x = new LockingExample();

    lock (x)

        x.MyMethod();

    此类锁定策略很容易就造成了死锁。客户代码在LockingExample对象上获取了锁,而MyMethod中却又尝试在同一个对象上获取锁。虽然这里的问题很容易看出来,不过也许某一天,另一个线程又在其他什么地方锁定了该Locking- Example对象。这时发生的死锁将很难找到其原因。

    我们需要改变锁定的策略,你可以采用下面将要介绍的三种方法。

    第一种方法是,若你需要保护整个一个方法,那么可以使用MethodImpl- Attribute属性来指定该方法是同步的。

    [MethodImpl(MethodImplOptions.Synchronized)]

    public void IncrementTotal()

    {

        total++;

    }

    不过显然,这种需求并不常见。

    第二种方法是,强制所有的开发者都必须仅锁定当前的类型或当前的对象,即使用lock(this)或lock(MyType)。若是每个人都遵守该规则的话,那么也不会出现什么问题。不过若想达到这个目标,需要你的程序的每个使用者都清楚地了解并遵守这个规则,即只能够锁定当前对象或当前类型,而不能是别的对象。这种理想的假设显然不够现实。

    第三种则是最好的办法。你可以在类中创建一个同步对象,专门用来保护访问共享的资源。该同步对象是一个私有成员变量,因此无法在类型之外访问到。你也可以保证该同步对象为私有,且不能被任何非私有属性访问。这样即可确保锁定语句安全地锁定到指定的对象上。

    通常,我们会创建一个System.Object对象作为同步对象。随后在类中访问需要保护的成员时即可锁定该同步对象。但在创建同步对象时需要小心,不要因为线程的交替执行创建出了多个同步对象的副本。Interlocked类的CompareExchange方法能够验证某个值,并在必要时替换成新值。我们可以使用该方法来保证类型中仅分配了一个同步对象。

    下面就是第三种做法的最简单实现。

    private object syncHandle = new object();

    public void IncrementTotal()

    {

        lock (syncHandle)

        {

            // 代码省略

        }

    }

    或许你会发现,程序并不需要经常地锁定,因此只要在需要锁定时创建同步对象即可。这时,创建锁定对象可采用如下的一种非常巧妙方式。

    private object syncHandle;

    private object GetSyncHandle()

    {

        System.Threading.Interlocked.CompareExchange(

            ref syncHandle, new object(), null);

        return syncHandle;

    }

    public void AnotherMethod()

    {

        lock (GetSyncHandle())

        {

            // 代码省略

        }

    }

    syncHandle用来控制类中对共享资源的访问。私有的GetSyncHandle()方法将返回同步对象。而不能被打断的CompareExchange调用则保证了程序仅会创建出一个同步对象。CompareExchange首先比较syncHandle和null,若syncHandle的当前值为null,那么CompareExchange将创建一个新的对象,并将其指派给syncHandle。

    这种做法适用于实例方法中的任何一种锁定,不过静态方法又该如何实现呢?仍旧使用类似的方法,不过创建的是一个静态的同步对象,从而让该类的所有实例都共用一个同步对象。

    当然,你可以在方法(属性访问器或索引器也可)内的任意一段代码上创建同步区块。不过不管怎样,你都应该尽可能地减少被锁定的代码。

    public void YetAnotherMethod()

    {

        DoStuffThatIsNotSynchronized();

        int val = RetrieveValue();

        lock (GetSyncHandle())

        {

            // 代码省略

        }

        DoSomeFinalStuff();

    }

    若是在lambda表达式中使用锁,那么必须小心处理。C#编译器将在lambda表达式外面创建一个闭包。该闭包和C# 3.0支持的延迟执行模型让开发者很难判断锁定何时才能结束,也就更容易发生死锁情况。因为开发者可能无法判断某段代码是否位于某个锁定区域内。

    在结束这个话题之前,还有另外两个锁定相关的建议。若你发现需要在某个类中创建不同的锁对象,那么或许应该考虑将该类拆分成多个类。因为该类做的事情太多了。若是需要保护对某些变量的访问,同时也需要用其他的锁来保护类中其他的变量,那么则非常建议你将类拆成具有不同责任的类。若每个类都是一个独立的单元,那么将更容易保证一致性。每个拥有共享数据(将由不同线程访问或修改)的类都应该仅用一个同步对象来保证其一致性。

    在考虑锁定时,应选择一个外部不可见的私有字段。不要锁定公共对象,因为锁定公共对象要求所有的开发者都永远遵循同样的规范,且非常容易导致死锁。

     

    条目15:避免在锁定区域内调用外部代码

    有些情况下,问题的出现是因为没有进行足够的锁定。不过当你再创建新的锁时,接下来却又可能发生死锁。当两个线程各持有了一个资源,同时也在等待对方的资源时,死锁自然难以避免。在.NET Framework中,有一种特殊情况是两个线程的执行几乎不分前后,同时进行。这时,虽然只有一个资源被锁定,但仍可能发生死锁。(条目16将介绍这种情况。)

    我们已经介绍了一种避免这个问题的最简单方法:条目13使用一个私有的数据成员作为同步对象,从而锁定了共享的数据。不过即使这样,仍有可能会导致死锁。若是在某段同步区域中调用了外部代码,那么另外的线程也有可能引发死锁。

    例如,使用如下代码来处理一个后台操作。

    public class WorkerClass

    {

        public event EventHandler<EventArgs> RaiseProgress;

        private object syncHandle = new object();

        public void DoWork()

        {

            for(int count = 0; count < 100; count++)

            {

                lock (syncHandle)

                {

                    System.Threading.Thread.Sleep(100);

                    progressCounter++;

                    if (RaiseProgress != null)

                        RaiseProgress(this, EventArgs.Empty);

                }

            }

        }

        private int progressCounter = 0;

        public int Progress

        {

            get

            {

                lock (syncHandle)

                    return progressCounter;

            }

        }

    }

    RaiseProgress()方法将通知所有的事件监听程序。所有的监听程序都可以注册并处理该事件。在多线程程序中,一个典型的事件处理程序将如下所示。

    static void engine_RaiseProgress(object sender, EventArgs e)

    {

        WorkerClass engine = sender as WorkerClass;

        if (engine != null)

            Console.WriteLine(engine.Progress);

    }

    程序运行不会出现什么问题,不过这也是只因为幸运而已。之所以没有问题,是因为事件处理程序运行于后台线程中。

    不过,假设该程序是一个Windows窗体应用程序,且你需要让事件处理程序在UI层上执行(参见条目16)。这可以使用Control.Invoke来实现。不仅如此,Control.Invoke还将阻塞原有线程,直到目标委托执行完毕为止。但这也不会出现什么问题——我们的操作运行于另一个线程上,因此不会导致死锁。

    而第二个重要的操作却导致了整个程序的死锁。为了获取进度的详细情况,事件处理程序使用了engine对象。不过其Progress访问器目前却是运行在另一个线程上,因此无法获取同样的锁。

    Progress访问器锁定了该同步对象。在本地上下文中,这没什么问题,不过实际并非如此。UI线程将需要锁定这个已经在后台线程中被锁定的同步对象。不过后台线程却也处于阻塞状态中,等待事件处理程序返回,同时后台线程已经锁定了该同步对象。这就难以避免地发生了死锁。

    表2-1给出了调用栈。可以看到查出此类问题并不容易。调用栈中,在第一个锁定和第二次尝试锁定之间有8种方法。而且,这些线程的交替执行均在框架内部发生,在真正发生问题时,你甚至无法看到这些详细的信息。

    表2-1 用来更新窗体显示的前后台线程执行过程的调用栈

    方  法

    线  程

    DoWork

    BackgroundThread

    raiseProgress

    BackgroundThread

    OnUpdateProgress

    BackgroundThread

    engine_OnUpdateProgress

    BackgroundThread

    Control.Invoke

    BackgroundThread

    UpdateUI

    UIThread

    Progress (property access)

    UIThread (deadlock)

    核心问题是代码需要再次获取一个锁。因为你不知道控件外部的代码将如何工作,所以应该尽量避免在同步区域内调用外部代码。在这个示例中,这就意味着你必须在同步区域之外触发进度报告事件。

    public void DoWork()

    {

        for(int count = 0; count < 100; count++)

        {

            lock (syncHandle)

            {

                System.Threading.Thread.Sleep(100);

                progressCounter++;

            }

            if (RaiseProgress != null)

                RaiseProgress(this, EventArgs.Empty);

        }

    }

    既然你已经看到了问题的所在,那么现在有必要让你完全理解调用未知代码所可能给应用程序带来的影响了。显然,触发公开的访问事件将调用外部代码。使用以参数形式传入或通过公开API设定的委托将调用外部代码。使用以参数形式传入的lambda表达式也可能会调用到外部代码(参见第5章条目40)。

    此类外部代码很容易发现,不过还有一个未知却并不那么容易找到:虚方法。虚方法调用的可能是派生类的重写版本,而这个重写的类中则可能调用任意的方法,也就有可能导致死锁。

    无论具体是什么情况,问题的成因都是相似的。你的类首先获取了一个锁。随后在同步区域内,调用了外部代码。该外部代码有可能会最终调用回你的类中,甚至在另外的线程上。你无法确保这些外部代码不做任何有害的事情。因此则必须从源头上杜绝:不要在代码的同步区域内调用外部代码。

     

    条目16:理解Windows窗体和WPF中的跨线程调用

    若你曾开发过Windows窗体程序,可能会注意到有时事件处理程序将抛出InvalidOperationException异常,信息为“跨线程调用非法:在非创建控件的线程上访问该控件”。这种Windows窗体应用程序中跨线程调用时的一个最为奇怪的行为就是,有些时候它没什么问题,可有些时候却会出现问题。在WPF(Windows Presentation Foundation)中,这个行为有所改变。WPF中跨线程调用将永远不会成功。不管怎样,至少这能让你在开发过程中更容易地找到问题的所在。

    在Windows窗体中,解决方法是首先检查Control.InvokeRequired属性,若Control. InvokeRequired属性为true,那么调用ControlInvoke()。在WPF中,可以使用System.Windows.Threading.Dispatcher中的Invoke()和BeginInvoke()方法。这两种情况中都发生了很多事情,你也同样有别的选择。这两个API为你做了很多事情,不过在某些情况下仍有可能会失败。因为这些方法将用来处理跨线程调用,因此若是没有正确使用(甚至是正确使用但没有完全理解其行为)的话,也有可能会导致竞争条件的出现。

    无论是Windows窗体还是WPF,问题的成因都很简单:Windows控件使用的是组件对象模型(Component Object Model,COM)单线程单元(Single-threaded Apartment,STA)模型,因为其底层的控件是单元线程(apartment-threaded)的。此外,很多控件都用消息泵(message pump)来完成操作。因此,这种模型就需要所有调用该控件的方法都和创建该控件的方法位于同一个线程上。Invoke、BeginInvoke和EndInvoke调度方法都需要在正确的线程上调用。两种模型的底层代码非常相似,因此这里将以Windows窗体的API为例。不过当调用方法有所区别时,我将同时给出两个版本。其具体的做法非常复杂,但仍需要深入了解。

    首先,我们来看一段简单的泛型代码,能够让你在遇到此种情况时得到一定的简化。匿名委托让仅在一处使用的小方法更加易于编写。不过,匿名委托却并不能与接受System.Delegate类型的方法(例如Control.Invoke)配合使用。因此,你需要首先定义一个非抽象的委托类型,随后在使用Control. Invoke时传入。

    private void OnTick(object sender, EventArgs e)

    {

        Action action = () =>

            toolStripStatusLabel1.Text =

                DateTime.Now.ToLongTimeString();

        if (this.InvokeRequired)

            this.Invoke(action);

        else

            action();

    }

    C# 3.0大大简化了上述代码。System.Core.Action委托定义了一类专门的委托类型,用来表示不接受任何参数并返回void的方法。lambda表达式也能够更加简单地定义方法体。但若你仍旧需要支持C# 2.0,那么需要编写如下的代码。

    delegate void Invoker();

    private void OnTick20(object sender, EventArgs e)

    {

        Action action = delegate()

        {

            toolStripStatusLabel1.Text =

                DateTime.Now.ToLongTimeString();

        };

        if (this.InvokeRequired)

            this.Invoke(action);

        else

            action();

    }

    在WPF中,则需要使用控件上的System.Threading.Dispatcher对象来执行封送操作。

    private void UpdateTime()

    {

        Action action = () => textBlock1.Text =

            DateTime.Now.ToString();

        if (System.Threading.Thread.CurrentThread !=

            textBlock1.Dispatcher.Thread)

        {

            textBlock1.Dispatcher.Invoke

                (System.Windows.Threading.DispatcherPriority.Normal,

                action);

        }

        else

        {

            action();

        }

    }

    这种做法让事件处理程序的实际逻辑变得更加模糊,让代码难以阅读和维护。这种做法还需要引入一个委托定义,仅仅用来满足方法的签名。

    使用一小段泛型代码即可改善这种情况。下面的这个ControlExtensions静态类所包含的泛型方法适用于调用不超过两个参数的委托。再添加一些重载即可支持更多的参数。此外,其中的方法还可使用委托定义来调用目标方法,既可以直接调用,也可以通过Control.Invoke的封送。

    public static class ControlExtensions

    {

        public static void InvokeIfNeeded(this Control ctl,

            Action doit)

        {

            if (ctl.InvokeRequired)

                ctl.Invoke(doit);

            else

                doit();

        }

        public static void InvokeIfNeeded<T>(this Control ctl,

            Action<T> doit, T args)

        {

            if (ctl.InvokeRequired)

                ctl.Invoke(doit, args);

            else

                doit(args);

        }

    }

    在多线程环境中使用InvokeIfNeeded能够很大程度上简化事件处理程序的代码。

    private void OnTick(object sender, EventArgs e)

    {

        this.InvokeIfNeeded(() => toolStripStatusLabel1.Text =

            DateTime.Now.ToLongTimeString());

    }

    对于WPF控件,也可以创建出一系列类似的扩展。

    public static class WPFControlExtensions

    {

        public static void InvokeIfNeeded(

            this System.Windows.Threading.DispatcherObject ctl,

            Action doit,

            System.Windows.Threading.DispatcherPriority priority)

        {

            if (System.Threading.Thread.CurrentThread !=

                ctl.Dispatcher.Thread)

            {

                ctl.Dispatcher.Invoke(priority,

                    doit);

            }

            else

            {

                doit();

            }

        }

        public static void InvokeIfNeeded<T>(

            this System.Windows.Threading.DispatcherObject ctl,

            Action<T> doit,

            T args,

            System.Windows.Threading.DispatcherPriority priority)

        {

            if (System.Threading.Thread.CurrentThread !=

                ctl.Dispatcher.Thread)

            {

                ctl.Dispatcher.Invoke(priority,

                    doit, args);

            }

            else

            {

                doit(args);

            }

        }

    }

    WPF版本没有检查InvokeRequired,而是检查了当前线程的标识,并于将要进行控件交互的线程进行比较。DispatcherObject是很多WPF控件的基类,用来为WPF控件处理线程之间的分发操作。注意,在WPF中还可以指定事件处理程序的优先级。这是因为WPF应用程序使用了两个UI线程。一个线程用来专门处理UI呈现,以便让UI总是能够及时呈现出动画等效果。你可以通过指定优先级来告诉框架哪类操作对于用户更加重要:要么是UI呈现,要么是处理某些特定的后台事件。

    这段代码有几个优势。虽然使用了匿名委托定义,不过事件处理程序的核心仍位于事件处理程序中。与直接使用Control.IsInvokeRequired或ControlInvoke相比,这种做法更加易读且易于维护。在ControlExtensions中,使用了泛型方法来检查InvokeRequired或是比较两个线程,这也就让使用者从中解脱了起来。若是代码仅在单线程应用程序中使用,那么我也不会使用这些方法。不过若是程序最终可能在多线程环境中运行,那么不如使用上面这种更加完善的处理方式。

    若想支持C# 2.0,那么还要做一些额外的工作。主要在于无法使用扩展方法和lambda表达式语法。这样,代码将变得有些臃肿。

    // 定义必要的Action:

    public delegate void Action;

    public delegate void Action<T>(T arg);

    // 3个和4个参数的Action定义省略

    public static class ControlExtensions

    {

        public static void InvokeIfNeeded(Control ctl, Action doit)

        {

            if (ctl.InvokeRequired)

                ctl.Invoke(doit);

            else

                doit();

        }

        public static void InvokeIfNeeded<T>( Control ctl,

            Action<T> doit, T args)

        {

            if (ctl.InvokeRequired)

                ctl.Invoke(doit, args);

            else

                doit(args);

        }

    }

    // 其他位置:

    private void OnTick20(object sender, EventArgs e)

    {

        ControlExtensions.InvokeIfNeeded(this, delegate()

        {

            toolStripStatusLabel1.Text =

              DateTime.Now.ToLongTimeString();

        });

    }

    在将这个方法应用到事件处理程序之前,我们来仔细看看InvokeRequired和Control.Invoke所做的工作。这两个方法并非没有什么代价,也不建议将这种模式应用到各处。Control.InvokeRequired用来判断当前代码是运行于创建该控件的线程之上,还是运行于另一个线程之上。若是运行于另一个线程之上,那么则需要使用封送。大多数情况下,这个属性的实现还算简单:只要检查当前线程的ID,并与创建该控件的线程ID进行比较即可。若二者匹配,那么则无需Invoke,否则就需要Invoke。这个比较并不需要花费太多时间,WPF版本的这类扩展方法也是执行了同样的检查。

    不过其中还有一些边缘情况。若需要判断的控件还没有被创建,在父控件已创建好,正在创建子控件时就可能发生这个情况。那么此时,虽然C#对象已经存在,不过其底层的窗口句柄仍旧为null。此时也就无法进行比较,因此框架本身将花费一定代价来处理这种情况。框架将沿着控件树向上寻找,看看是否有上层控件已被创建。若是框架能够找到一个创建好了的窗体,那么该窗体将作为封送窗体。这是一个非常合理的假设,因为父控件将要负责创建子控件。这种做法可以保证子控件将会与父控件在同一个线程上创建。找到合适的父控件之后,框架即可执行同样的检查,比较当前线程的ID和创建该父控件的线程的ID。

    不过,若是框架无法找到任何一个已创建的父窗体,那么则需要找到一些其他类型的窗体。若在层次体系中无法找到可用的窗体,那么框架将开始寻找暂存窗体(parking window),暂存窗体让你不会被某些Win32 API奇怪的行为所干扰。简而言之,有些对窗体的修改(例如修改某些样式)需要销毁并重新创建该窗体。暂存窗体就是用来在父窗体被销毁并重新创建的过程中用来临时保存其中的控件的。在这段时间内,UI线程仅运行于暂存窗体中。

    在WPF中,得益于Dispatcher类的使用,上述很多过程都得到了简化。每个线程都有一个Dispatcher。在第一次访问某个控件的Dispatcher时,类库将察看该线程是否已经拥有了Dispatcher。若已经存在,那么直接返回。如果没有的话,那么将创建一个新的Dispatcher对象,并关联在控件及其所在的线程之上。

    不过这其中仍旧有可能存在着漏洞和发生失败。有可能所有的窗体,包括暂存窗体都没有被创建。在这种情况下,InvokeRequired将返回false,表示无需将调用封送到另一个线程上。这种情况可能会比较危险,因为这个假设可能是错误的,但框架也仅能做到如此了。任何需要访问窗体句柄的方法都无法成功执行,因为现在还没有任何窗体。此外,封送也自然会失败。若是框架无法找到任何可以封送的控件,自然也无法将当前调用封送到UI线程上。于是框架选择了一个可能在稍后出现的失败,而不是当前会立即出现的失败。幸运的是,这种情况在实际中非常少见。不过在WPF中,Dispatcher还是包含了额外的代码来预防这种情况。

    总结一下InvokeRequired的相关内容。一旦控件创建完成,那么InvokeRequired的效率将会不错,且也能保证安全。不过若是目标控件尚未被创建,那么InvokeRequired则可能会耗费比较长的时间。而若是没有创建好任何控件,那么InvokeRequired则可能要相当长的时间,同时其结论也无法保证正确。但虽然Control.InvokeRequired有可能耗时较长,也比非必要地调用Control.Invoke要高效得多。且在WPF中,很多边缘情况都得到了优化,性能要比Windows窗体的实现提高不少。

    接下来看看Control.Invoke的执行过程。(Control.Invoke的执行非常复杂,因此这里将仅做简要介绍。)首先,有一个特殊情况是虽然调用了Invoke方法,不过当前线程却和控件的创建线程一样。这是个最为简单的特例,框架将直接调用委托。即当InvokeRequired返回false时仍旧调用Control.Invoke()将会有微小的损耗,不过仍旧是安全的。

    在真正需要调用Invoke时会发生一些有趣的情况。Control.Invoke能够通过将消息发送至目标控件的消息队列来实现跨线程调用。Control.Invoke还创建了一个专门的结构,其中包含了调用委托所需要的所有信息,包括所有的参数、调用栈以及委托的目标等。参数均会被预先复制出来,以避免在调用目标委托之前被修改(记住这是在多线程的世界中)。

    在创建好这个结构并添加到队列中之后,Control.Invoke将向目标对象发送一条消息。Control.Invoke随后将在等待UI线程处理消息并调用委托时组合使用旋转等待(spin wait)和休眠。这部分的处理包含了一个重要的时间问题。当目标控件开始处理Invoke消息时,它并不会仅仅执行一个委托,而是处理掉队列中所有的委托。若你使用的是Control.Invoke的同步版本,那么不会看到任何效果。不过若是混合使用了Control.Invoke和Control.BeginInvoke,那么行为将有所不同。这部分内容将在稍后继续介绍,目前需要了解的是,控件的WndProc将在开始处理消息时处理掉每一个等待中的Invoke消息。对于WPF,可控制的要多一些,因为可以指定异步操作的优先级。你可以让Dispatcher将消息放在队列中时给出三种优先级:(1)基于系统或应用程序的当前状况;(2)使用普通优先级;(3)高优先级。

    当然,这些委托中可能会抛出异常,且异常无法跨线程传递。因此框架将把对委托的调用用try/catch包围起来并捕获所有的异常。随后在UI线程完成处理之后,其中发生的异常将被复制到专门的数据结构中,供原线程分析。

    在UI线程处理结束之后,Control.Invoke将察看UI线程中抛出的所有异常。如果确有异常发生,那么将在后台线程中重新抛出。若没有异常,那么将继续进行普通的处理。可以看到,调用一个方法的过程并不简单。

    Control.Invoke将在执行封送调用时阻塞后台线程,虽然实际上在多线程环境中运行,不过仍旧让人觉得是同步的行为。

    不过这可能不是你所期待的。很多时候,你希望让工作线程触发一个事件之后继续进行下面的操作,而不是同步地等待UI。这时则应该使用BeginInvoke。该方法的功能和Control.Invoke基本相同,不过在向目标控件发送消息之后,BeginInvoke将立即返回,而不是等待目标委托完成。BeginInvoke支持发送消息(可能在稍后才会处理)后立即返回到调用线程上。你可以根据需要为ControlExtensions类添加相应的异步方法,以便简化异步跨线程UI调用的操作。虽然与前面的那些方法相比,这些方法带来的优势不那么明显,不过为了保持一致,我们还是在ControlExtensions中给出。

    public static void QueueInvoke(this Control ctl, Action doit)

    {

        ctl.BeginInvoke(doit);

    }

    public static void QueueInvoke<T>(this Control ctl,

        Action<T> doit, T args)

    {

        ctl.BeginInvoke(doit, args);

    }

    QueueInvoke并没有在一开始检查InvokeRequired。这是因为即使当前已经运行于UI线程之上,你仍可能想要异步地调用方法。BeginInvoke()就实现了这个功能。Control.BeginInvoke将消息发送至目标控件,然后返回。随后目标控件将在其下一次检查消息队列时处理该消息。若是在UI线程中调用的BeginInvoke,那么实际上这并不是异步的:当前操作后就会立即执行该调用。

    这里我忽略了BeginInvoke所返回的Asynch结果对象。实际上,UI更新很少带有返回值。这会大大简化异步处理消息的过程。只需简单地调用BeginInvoke,然后等待委托在稍后的某个时候执行即可。但编写委托方法时需要格外小心,因为所有的异常都会在跨线程封送中被默认捕获。

    在结束这个条目之前,我再来简单介绍一下控件的WndProc。当WndProc接收到了Invoke消息之后,将执行InvokeQueue中的每一个委托。若是希望按照特定的顺序处理事件,且你还混合使用了Invoke和BeginInvoke,那么可能会在时间上出现问题。可以保证的是,使用Control. BeginInvoke或Control.Invoke调用的委托将按照其发出的顺序执行。BeginInvoke仅仅会在队列中添加一个委托。不过稍后的任意一个Control.Invoke调用均会让控件开始处理队列中所有的消息,包括先前由BeginInvoke添加的委托。“稍后的某一时间”处理委托意味着你无法控制“稍后的某一事件”到底是何时。“现在”处理委托则意味着应用程序先执行所有等待的异步委托,然后处理当前的这一个。很有可能的是,某个由BeginInvoke发出的异步委托将在Invoke委托调用之前改变了程序的状态。因此需要小心地编写代码,确保在委托中重新检查程序的状态,而不是依赖于调用Control.Invoke时传入的状态。

    简单举例,如下版本的事件处理程序很难显示出那段额外的文字。

    private void OnTick(object sender, EventArgs e)

    {

        this.InvokeAsynch(() => toolStripStatusLabel1.Text =

            DateTime.Now.ToLongTimeString());

        toolStripStatusLabel1.Text += "  And set more stuff";

    }

    这是因为第一个修改会被暂存于队列中,随后在开始处理接下来的消息时才会修改文字。而此时,第二条语句已经给标签添加了额外的文字。

    Invoke和InvokeRequired为你默默地做了很多的工作。这些工作都是必需的,因为Windows窗体控件构建于STA模型之上。这个行为在最新的WPF中依旧存在。在所有最新的.NET Framework代码之下,原有的Win32 API并没有什么变化。因此这类消息传递以及线程封送仍旧可能导致意料之外的行为。你必须对这些方法的工作原理及其行为有着充分的理解。

    展开全文
  • 、在同个局域网内(无论是连接的WiFi还是网线),工作的需要,需要共享一些文件: 1、找到需要共享的文件夹(或者文件)位置,如下图,比如需要共享“我的电脑的C盘” 2、在对应的文件夹,也就是桌面文件夹...

    关于文件共享,总结了几种实现方式,大家可以根据自己的需求选择合适的共享方式

    一、在同一个局域网内(无论是连接的WiFi还是网线),工作的需要,需要共享一些文件:

    1、找到需要共享的文件夹(或者文件)位置,如下图,比如需要共享“我的电脑的C盘”

    2、在对应的文件夹,也就是桌面文件夹,右键鼠标,点击属性

     

    3、进入属性面板后,选择共享,然后点击高级共享

     

    4、进入高级共享后,勾选“共享此文件夹”,原来不可点击的权限变成可以点击的状态

     

    5、点击权限,给制定用户赋予权限,这里为了说明,直接选择everyone,也就是任何人都可以访问

     

    6、点击确定,应用后,回到属性界面,复制网络路径,发给准备共享的人A

     

    7、在另外一台机器上(用户A)的“文件资源管理地址”中(或者WINDOWS+R:输入“\\地址名称”)粘贴后,回车,即可看到分享的文件了

    (1)方式一

    (2)方式二:

    以上,为其中一种操作步骤,A用户可以随时通过“资源管理器”中“网络”查看局域网中其他同事共享的文件(如图),

    同时,共享者,也可以查看自己哪些文件共享到局域网状态(显示一个共享标志,如下图所示):

    二、方式二:设置自己的IP网络地址

    1. 按“Windows”+“R”,打开运行窗口,输入“control”点击确定。

      两台电脑怎么共享文件

    2. 点击“网络和internet”,再点击“网络和共享中心”

      两台电脑怎么共享文件

    3. 点击相关网络,选择“属性”,双击“Internet协议版本4”,勾选“使用下面的IP地址”。

      两台电脑怎么共享文件

    4. 在“IP地址”和“子网掩码”中分别输入“192.168.1.55”(此地址最后一个字段可以自由设置)和“255.255.255.0”。

      4e4a20a4462309f7ec2c2c017e0e0cf3d7cad666.jpg

    5. 在另一台电脑上重复以上1-4操作,再次按“Windows”+“R”,打开运行窗口,输入“control”点击确定。

       

    6. 找到“系统和安全”,点击“Windows防火墙”。

      两台电脑怎么共享文件

    7. 找到左边的“启用或关闭Windows防火墙”,勾选“关闭Windows防火墙”。

      两台电脑怎么共享文件

    8. 确定后,点击一个文件,右键单击“属性”,选择“共享栏”点击“共享”。(此时,重复“一”中第六条往后即可)

    注:如以上两种方法均查看不到局域网共享文件,按以下方式进行操作

    1、找到“网络和internet连接“在自己的本地电脑的右下角有一个上网连接的标志,点击它,会弹出一个菜单,选择“网络和internet连接”并点击它。

           

    2、进入网络和internet连接的界面。在左边的菜单栏中,有以太网的设置,和vpn以及代理等上网的设置。

          此时在搜索栏输入“共享”,然后点击“管理高级共享设置”

    • 点击开启。

      在共享选项设置里面,找到“网络发现”和“文件和打印机共享”,将两个的“启用网络发现”开关都打开,然后点击确定。

    展开全文
  • 2 台电脑共享键鼠最简单教程

    千次阅读 2021-01-20 16:18:13
    台电脑连接到同个路由器或交换机,或者连接到同个 Wifi,保证能够相互 ping 通 两台电脑上分别安装同版本的 synergy 共享键鼠软件 最好使用路由器或者交换机,如果网络不稳定使用 Wifi 共享会卡顿 二、...
  • 三个以上记事本窗口做相同操作,对任意窗口进行手动操作,其余窗口由脚本执行相同步骤的键鼠操作   编写个脚本,对至少三个以上记事本窗口做相同操作,对任意窗口进行手动操作,其余窗口由脚本执行...
  • 多电脑切换的原理和功能介绍

    千次阅读 2014-11-24 14:06:18
    管理软件这样的单一界面直接访问位于个远程位置的服务器和设备。KVM over IP 解决方案现在已具备完善的地点故障转移功能、符合新服务器管理标准 (IPMI) 的直接界面,以及将本地存储媒体映射至远程位置的功能。 ...
  • windows server 2008 r2 个服务器时间同步 本文参考如下文章编写: https://blog.csdn.net/wohaqiyi/article/details/82381706 https://jingyan.baidu.com/article/da1091fb1aa9a6027849d6cb.html 、配置...
  • 线程解决窗口售票问题

    千次阅读 2016-12-11 17:26:25
    如果用过迅雷的人,就会发现,迅雷的速度比普通的下载下载速度要快。是它有单用的网速通道吗?这是因为迅雷开启了线程,加快了下载速度。  什么是进程?  进程就是正在运行的程序。开启QQ就是开启了个进程,...
  • 程序员真的需要一台 Mac 吗?

    千次阅读 2019-05-27 16:44:19
    程序员选电脑只推荐15寸的MacBook Pro和13寸的MacBook Pro 稳定性和强大的功能 Mac OS X稳定性的基础是 Darwin,它是系统的开放源代码内核。 Darwin集成了大量技术,包括Mach30内核、基于 BSD UNⅨX(伯克利软件分发)的...
  • 2、为什么要显卡渲染 3、显卡渲染核心原理 3.1、GPU拓扑模型及工作方式 3.1.1、隐式显卡系统 3.1.2、显式显卡系统 3.1.3、链接的显卡系统 3.1.4、无链接的显卡系统 3.2、无链接显式显卡系统...
  • chrome主动同步书签

    2021-02-26 10:53:26
    1-先关闭所有的chrome浏览器窗口 2-确保能上google 3-在chrome中输入chrome://sync/ chrome://sync/ 下面就是请求是否成功的信息; 请求成功的时候代表开始同步了;可以自己在另一台电脑上新建书签来验证这个 ...
  • 后台程序进入电脑的命令

    千次阅读 2009-11-15 22:16:00
    后台程序进入电脑的命令,当然别人的pc也可以...... net use [url=file://ip/ipc$]//ip/ipc$[/url] " " /user:" " 建立IPC空链接net use [url=file://ip/ipc$]//ip/ipc$[/url] "密码" /user:"用户名" 建立IPC非空...
  •  当个进程启动了个线程时,如果需要控制这些线程的推进顺序(比如A线程必须等待B和C线程执行完毕之后才能继续执行),则称这些线程需要进行“线程同步(thread synchronization)”。  线程同步的道理虽然...
  • 怎样在两个局域网内共享一台打印机  怎样在两个局域网内共享一台打印机  我们公司有两间办公室,原先布线的时候用一个路由器延伸出个接口预埋在墙里并做上插头,IP地址是自动分配的,网关是192.168.0.1,但是...
  • X窗口系统

    千次阅读 2012-10-17 14:19:38
    X窗口系统(X Window System,也常称为X11或X)是种以位图方式显示的软件窗口系统。最初是1984年麻省理工学院的研究,之后变成UNIX、类UNIX、以及OpenVMS等操作系统所一致适用的标准化软件工具包及显示架构的运作...
  • :“同步主机_XXXXX“的服务项,据说是个没什么用的垃圾同步功能,关闭该服务能有效解决磁盘100%的问题。 关闭方法: 1、按下WIN+R调出运行,然后输入 regedit 回车; 2、在注册表编辑中定位到:HKEY_LOCAL_...
  • 用开源飞控套件做架Mini四轴飞行

    万次阅读 多人点赞 2016-07-29 13:55:21
    用开源飞控套件做架Mini四轴飞行 四轴飞行已经不是什么新鲜的东西,世界上很不太平的地方也用某疆的四轴做侦察,你只要花几千块钱,就可以买到一套“进入白宫同款”的四轴无人机。不过,...
  • JavaSE入门 P14 【进阶】线程、同步、匿名内部类、死锁、生命周期 1.概述 2.线程实现(模拟卖票) 3.线程安全问题&解决方案 4.匿名内部类 5.同步 6.死锁,线程生命周期 7.面试题
  • 电脑知识()

    千次阅读 2007-10-31 23:04:00
    系统启动问题先装好了WindowsXP,然后再安装Windows 2000。开机后发现Windows XP的滚动条消失后,电脑即黑屏,无法进入登录画面。 其实,这个问题是因为当安装好Windows 2000后,某些系统文件从高版本变回低版本,...
  • 如何判断一台机器是否属于域

    千次阅读 2005-12-29 09:40:00
    解决问题的方向查看 Windows LOGON 的描述.1....拥有工作站,桌面,键盘,鼠标的独占控制.GINA: Graphical Identification and Authentication,图形身份验证GINA STUB: 实现部分GINA接口的个DLL.LSA:
  • 利用VB.Net编程实现PC与掌上电脑PPC间的双向通信  [源文件下载]http://www.cnblogs.com/Risen/category/110585.html本文介绍如何利用VB.Net 通过Windows Sockets (Winsock)以及线程编程进行桌面电脑与Pocket...
  • 如何高效在个浏览器之间同步使用的5个工具技巧  对于网站前端开发者、用户体验设计师、互联网产品工程人员,以及广大站长、博客主、深度用户而言,往往有着个共同的需求:需要时常在个浏览器之间...
  • windows 命令行窗口

    千次阅读 2012-06-13 11:22:23
     MOVE 将个或个文件从个目录移动到另个目录。  OPENFILES 显示远程用户为了文件共享而打开的文件。  PAGEFILECONFIG 显示或配置页面文件的属性。  PATH 为可执行文件显示或设置搜索路径。 ...
  • 例如,你所使用的机子处于个连接到Internet的局域网内,你在机子上所开的所有服务(如FTP),默认情况下外界是访问不了的。这是因为你机子的IP是局域网内部IP,而外界能访问的只有你所连接的服务
  • windows 实时自动同步两个文件夹

    千次阅读 2020-03-24 21:22:23
    一台windows电脑上有两个文件夹A和B,其中A是主文件夹,B需要实时备份A中的内容。 解决方案: 1.使用SyncToy软件同步两个文件夹; 但SyncToy不能实时自动同步,需要手动同步。 2.使用批处理文件不断循环运行Sync...
  • 中国国家授时中心的时间服务器IP地址及时间同步方法(附个时间服务器地址) 大家都知道计算机电脑的时间是由块电池供电保持的,而且准确度比较差经常出现走时不准的时候。通过互联网络上发布的一些公用网络时间...
  • 1. 选择一台服务器作为时间同步服务器。 2. 运行Regedit,打开注册表编辑。 3. 找到注册表项HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\W32Time\Config\,在右侧窗口中将AnnounceFlags的值修改为5。...
  • WPF窗口跳转及window和page区别

    千次阅读 2018-11-15 02:39:35
    WPF窗口跳转及window和page区别

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 18,071
精华内容 7,228
关键字:

一台电脑多窗口同步器