原文:如何为非常不确定的行为(如并发)设计安全的 API,使用这些 API 时如何确保安全

.NET 中提供了一些线程安全的类型,如 ConcurrentDictionary<TKey, TValue>,它们的 API 设计与常规设计差异很大。如果你对此觉得奇怪,那么正好阅读本文。本文介绍为这些非常不确定的行为设计 API 时应该考虑的原则,了解这些原则之后你会体会到为什么会有这些 API 设计上的差异,然后指导你设计新的类型。


不确定性

像并发集合一样,如 ConcurrentDictionary<TKey, TValue>ConcurrentQueue<T>,其设计为线程安全,于是它的每一个对外公开的方法调用都不会导致其内部状态错误。但是,你在调用其任何一个方法的时候,虽然调用的方法本身能够保证其线程安全,能够保证此方法涉及到的状态是确定的,但是一旦完成此方法的调用,其状态都将再次不确定。你只能依靠其方法的返回值来使用刚刚调用那一刻确定的状态。

我们来看几段代码:

var isRunning = Interlocked.CompareExchange(ref _isRunning, 1, 0);
if (isRunning is 1)
{
// 当前已经在执行队列,因此无需继续执行。
}
  • 1
  • 2
  • 3
  • 4
  • 5
private ConcurrentDictionary<string, object> KeyValues { get; }
= new ConcurrentDictionary<string, object>(); object Get(string key)
{
var value = KeyValues.TryGetValue(key, out var v) ? v : null;
return value;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

这两段代码都使用到了可能涉及线程安全的一些代码。前者使用 Interlocked 做原则操作,而后者使用并发字典。

无论写上面哪一段代码,都面临着问题:

  • 此刻调用的那一句话得到的任何结果都仅仅只表示这一刻,而不代表其他任何代码时的结果。

比如前者的 Interlocked.CompareExchange(ref _isRunning, 1, 0) 我们得到一个返回值 isRunning,然后判断这个返回值。但是我们绝对不能够判断 _isRunning 这个字段,因为这个字段非常易变,在你的任何一个代码上下文中都可能变成你不希望看到的值。Interlocked 是原子操作,所以才确保安全。

而后者,此时访问得到的字典数据,和下一时刻访问得到的字典数据将可能完全不匹配,两次的数据不能通用。

API 用法指导

如果你正在为一个易变的状态设计 API,或者说你需要编写的类型带有很强的不确定性(类型状态的变化可能发生在任何一行代码上),那么你需要遵循一些设计原则才能确保安全。

同一个上下文仅能查看或修改一次状态

比如要为缓存设计一个获取可用实例的方法,可以使用:

private ConcurrentDictionary<string, object> KeyValues { get; }
= new ConcurrentDictionary<string, object>(); void Get(string key)
{
// CreateCachedInstance 是一个工厂方法,所有 GetOrAdd 的地方都是用此工厂方法创建。
var value = KeyValues.GetOrAdd(key, CreateCachedInstance);
return value;
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

但是绝对不能使用:

if(!KeyValues.TryGetValue(key, out var v))
{
KeyValues.TryAdd(key, CreateCachedInstance(key));
}
  • 1
  • 2
  • 3
  • 4

这一段代码就是对并发的状态 KeyValues 做了两次访问。

ConcurrentDictionary 也正是考虑到了这种设计场景,于是才提供了 API GetOrAdd 方法。让你在获取对象实例的时候可以通过工厂方法去创建实例。

如果你需要设计这种状态极易变的 API,那么需要针对一些典型的设计场景提供一次调用就能获取此时此刻所有状态的方法。就像上文的 GetOrAdd 一样。

另一个例子,WeakReference<T> 弱引用对象的管理也是在一个方法里面可以获取到一个绝对确定的状态,而避免使用方进行两次判断:

if (weak.TryGetTarget(out var value))
{
// 一旦这里拿到了对象,这个对象一定会存在且可用。
}
  • 1
  • 2
  • 3
  • 4

一定不能提供两个方法调用来完成这样的事情(比如先判断是否存在再获取对象的实例,就像 .NET Framework 4.0 和早期版本弱引用的 API 设计一样)。

对于并发,如果有多次查看或者修改状态,必须加锁

比如以下方法,是试图一个接一个地依次执行 _queue 中的所有任务。

虽然我们使用 Interlocked.CompareExchange 原子操作,但因为后面依然涉及到了多次状态的获取,导致不得不加锁才能确保安全。我们依然使用原则操作是为了避免单纯 lock 带来的性能损耗。

private volatile int _isRunning;
private readonly object _locker = new object();
private readonly ConcurrentQueue<TaskWrapper> _queue = new ConcurrentQueue<TaskWrapper>(); private async void Run()
{
var isRunning = Interlocked.CompareExchange(ref _isRunning, 1, 0);
if (isRunning is 1)
{
lock (_locker)
{
if (_isRunning is 1)
{
// 当前已经在执行队列,因此无需继续执行。
return;
}
}
} var hasTask = true;
while (hasTask)
{
// 当前还没有任何队列开始执行,因此需要开始执行队列。
while (_queue.TryDequeue(out var wrapper))
{
// 内部已包含异常处理,因此外面可以无需捕获或者清理。
await wrapper.RunAsync().ConfigureAwait(false);
} lock (_locker)
{
hasTask = _queue.TryPeek(out _);
if (!hasTask)
{
_isRunning = 0;
}
}
}
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39

这段代码的完全解读:

  1. 当执行 Run 方法的时候,先判断当前是否已经在跑其他的任务:

    • isRunning0 表示当前一定没有在跑其他任务,我们使用原则操作立刻将其修改为 1
    • isRunning1 表示当前不确定是否在跑其他任务;
  2. 既然 isRunning1 的时候状态不确定,于是我们加锁来判断其是否真的有任务在跑:
    • lock 环境中确认 _isRunning 字段而非变量为 1 则说明真的有任务在跑,此时等待任务完成即可,这里就可以退出了;
    • lock 环境中发现 _isRunning 字段而非变量为 0 则说明实际上是没有任务在跑的(刚刚判断为 1 只是因为这两次判断之间,并发的任务刚刚在结束的过程中),于是需要跟一开始判断为 0 一样,进入到后面的循环中;
  3. 外层的 while 循环第一次是一定能进去的,于是我们暂且不谈;
  4. while 内循环中,我们依次检查并发队列 _queue 中是否还有任务要执行,如果有要执行的,就执行:
    • 这个过程我们完全没有做加锁,因为这可能是非常耗时的任务,如果我们加锁,将导致其他线程出现非常严重的资源浪费;
  5. 如果 queue 中的所有任务执行完毕,我们将进入一个 lock 区间:
    • 在这个 lock 区间里面我们再次确认任务是否已经完成,如果没有完成,我们靠最外层的 while 循环重新回到内层 while 循环中继续任务;
    • 如果在这个 lock 区间里面我们发现任务已经完成了,就设置 _isRunning0,表示任务真的已经完成,随后退出 while 循环;

你可以注意到我们的 lock 是用来确认一开始 isRunning1 时的那个不确定的状态的。因为我们需要多次访问这个状态,所以必须加锁来确认状态是同步的。

API 设计指导

在了解了上面的用法指导后,API 设计指导也呼之欲出了:

  1. 针对典型的应用场景,必须设计一个专门的方法,一次调用即可完全获取当时需要的状态,或者一次调用即可完全修改需要修改的状态;
  2. 不要提供大于 1 个方法组合在一起才能使用的 API,这会让调用方获取不一致的状态。

对于多线程并发导致的不确定性,使用方虽然可以通过 lock 来规避以上第二条问题,但设计方最好在设计之初就避免问题,以便让 API 更好使用。

关于通用 API 设计指导,你可以阅读我的另一篇双语博客:


我的博客会首发于 https://blog.walterlv.com/,而 CSDN 会从其中精选发布,但是一旦发布了就很少更新。

如果在博客看到有任何不懂的内容,欢迎交流。我搭建了 dotnet 职业技术学院 欢迎大家加入。

本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。欢迎转载、使用、重新发布,但务必保留文章署名吕毅(包含链接:https://walterlv.blog.csdn.net/),不得用于商业目的,基于本文修改后的作品务必以相同的许可发布。如有任何疑问,请与我联系

发布了382 篇原创文章 · 获赞 232 · 访问量 47万+

如何为非常不确定的行为(如并发)设计安全的 API,使用这些 API 时如何确保安全的更多相关文章

  1. mapreduce中一个map多个输入路径

    package duogemap; import java.io.IOException; import java.util.ArrayList; import java.util.List; imp ...

  2. In-Memory:内存数据库

    在逝去的2016后半年,由于项目需要支持数据的快速更新和多用户的高并发负载,我试水SQL Server 2016的In-Memory OLTP,创建内存数据库实现项目的负载需求,现在项目接近尾声,系统 ...

  3. 从直播编程到直播教育:LiveEdu.tv开启多元化的在线学习直播时代

    2015年9月,一个叫Livecoding.tv的网站在互联网上引起了编程界的注意.缘于Pingwest品玩的一位编辑在上网时无意中发现了这个网站,并写了一篇文章<一个比直播睡觉更奇怪的网站:直 ...

  4. Hadoop 中利用 mapreduce 读写 mysql 数据

    Hadoop 中利用 mapreduce 读写 mysql 数据   有时候我们在项目中会遇到输入结果集很大,但是输出结果很小,比如一些 pv.uv 数据,然后为了实时查询的需求,或者一些 OLAP ...

  5. 【.net 深呼吸】细说CodeDom(8):分支与循环

    有人会问,为啥 CodeDom 不会生成 switch 语句,为啥没生成 while 语句之类.要注意,CodeDom只关心代码逻辑,而不是语法,语法是给写代码的人用的.如果用.net的“反编译”工具 ...

  6. 避免重复造轮子的UI自动化测试框架开发

    一懒起来就好久没更新文章了,其实懒也还是因为忙,今年上半年的加班赶上了去年一年的加班,加班不息啊,好了吐槽完就写写一直打算继续的自动化开发 目前各种UI测试框架层出不穷,但是万变不离其宗,驱动PC浏览 ...

  7. 关于DOM的操作以及性能优化问题-重绘重排

     写在前面: 大家都知道DOM的操作很昂贵. 然后贵在什么地方呢? 一.访问DOM元素 二.修改DOM引起的重绘重排 一.访问DOM 像书上的比喻:把DOM和JavaScript(这里指ECMScri ...

  8. Angular2入门系列教程7-HTTP(一)-使用Angular2自带的http进行网络请求

    上一篇:Angular2入门系列教程6-路由(二)-使用多层级路由并在在路由中传递复杂参数 感觉这篇不是很好写,因为涉及到网络请求,如果采用真实的网络请求,这个例子大家拿到手估计还要自己写一个web ...

  9. Angular2学习笔记(1)

    Angular2学习笔记(1) 1. 写在前面 之前基于Electron写过一个Markdown编辑器.就其功能而言,主要功能已经实现,一些小的不影响使用的功能由于时间关系还没有完成:但就代码而言,之 ...

  10. 防御XSS攻击-encode用户输入内容的重要性

    一.开场先科普下XSS 跨站脚本攻击(Cross Site Scripting),为不和层叠样式表(Cascading Style Sheets, CSS)的缩写混淆,故将跨站脚本攻击缩写为XSS.恶 ...

随机推荐

  1. ICEM-三棱锥的一种画法

    原视频下载地址:https://pan.baidu.com/s/1c4BAqy 密码: nqb4

  2. koa2 get post api restful前端联调

    1.git https://github.com/MengFangui/koa2-restful-api 2.示例代码 //引入 Koa 服务器 const koa = require('koa'); ...

  3. tensorflow运行时openssl与boringssl兼容性问题

    tensorflow中,grpc使用boringssl进行加密,boringssl是google基于openssl自行开发的一条分支,有许多相同函数,但底层实现以及支持的加密类型有不同. 且tenso ...

  4. 利用C++ STL的vector模拟邻接表的代码

    关于vector的介绍请看 https://www.cnblogs.com/zsq1993/p/5929806.html https://zh.cppreference.com/w/cpp/conta ...

  5. checkbox与label内的文字垂直居中的解决方案

    <label style="float:left;margin-top:5px;margin-left:10px;cursor:pointer"><input t ...

  6. [转]Java 之 Serializable 序列化和反序列化的概念,作用的通俗易懂的解释

    原文地址:https://blog.csdn.net/qq_27093465/article/details/78544505 遇到这个 Java Serializable 序列化这个接口,我们可能会 ...

  7. linux,卸载文件系统的时候,报busy情况的解决记录

    背景描述: 前几天由于文件系统io异常的问题,要对文件系统的属性进行修改,修改该参数需要将磁盘umount,在umount的过程中遇到问题,在此记录下. 处理过程: 1.执行umount进行卸载磁盘, ...

  8. SpringBoot入门-概念(一)

    SpringBoot是什么 Spring boot是一个构建在Spring框架之上.以一种更加简单快捷的方式来配置和运行web应用程序的开源框架. 为什么用SpringBoot 可以解决普通的java ...

  9. SpringCloud中遇到的问题总结

    1.如果数据库URL字符串中不加serverTimezone=GMT%2B8且数据库未设置时区,会报如下错误 Caused by: com.mysql.cj.exceptions.InvalidCon ...

  10. shell 数学计算的N个方法

    let使用方法 root@172-18-21-195:/tmp# n1=5 root@172-18-21-195:/tmp# n2=10 root@172-18-21-195:/tmp# let re ...