【问题标题】:Compiler detection of race conditions between Tasks编译器检测任务之间的竞争条件
【发布时间】:2020-11-26 19:52:23
【问题描述】:

我的日常工作是使用对安全至关重要的嵌入式系统。我还与客户就编写安全嵌入式代码的主题进行了一些教学/咨询。编程语言的问题总是会出现,我们比较 C、D、Ada、Erlang、Rust 等。

我经常使用一个练习来进行演示。这是一个简单的双线程程序,每个线程获取一个全局变量(初始化为 0),将其加 1 并替换它十次。然后我们推测变量在末尾可以具有的最大值 (20) 和它的最小值(我们通常在使用正式证明证明它可以是 2 之前决定 10)。

我演示的一件事是程序的 C 版本可以编译(危险),但 Rust 版本不能(好!)。今天我写了 Ada 版本,并且有两个惊喜,我想请教 cmets。首先,我的程序:

with Ada.Text_IO; use Ada.Text_IO;

procedure Main is
   task AddTenA;
   task AddTenB;

   --  Global variable

   x : Natural := 0;

   finished : array (0 .. 1) of Natural := (0, 0);

   --  Make sure that the compiler doesn't remove
   --  all the addition.

   pragma Volatile (x);

   task body AddTenA is
      y : Integer;
   begin
      for I in 1 .. 10 loop
         y := x + 1;
         x := y;
      end loop;
      finished (0) := 1;
   end AddTenA;
   
   task body AddTenB is
      y : Integer;
   begin
      for I in 1 .. 10 loop
         y := x + 1;
         x := y;
      end loop;
      finished (1) := 1;
   end AddTenB;
   
begin

   while finished (0) + finished (1) < 2 loop
      delay 0.001;
   end loop;

   Put_Line (Integer'Image (x));

end Main;

是的,我熟悉受保护对象和任务集合点,但这不是程序的重点。

我的两个惊喜:

  1. 即使有完整的编译器标志字母表(-fstack-check、-gnata、-gnato13、-gnatf、-gnatwa、-g、-gnatVa、-gnaty3abcdefhiklmnoOprstux、-gnatwe、-gnat2012、-Wall、-O2)我没有收到编译器警告。 Rust 告诉我全局变量没有唯一的所有者,因此它不会为我编译 Rust 版本的程序。我知道 SPARK 不处理任务,因此 Ada 不会生成任何警告,表明我在代码中存在潜在危险的竞争条件。这让我对 Ada 这样的语言感到惊讶。我错过了一个聪明的编译器或运行时选项吗?

  2. 当我执行等效的 C 程序时,最常见的输出是 20,但是当我多次运行它时,我得到的值是分散的,通常从大约 8 到 20。我已经运行了 Ada 程序(上图) 500,000 次,只得到 10 和 20 的值(中间没有值,99.9% 的输出是 20)。这表明 C 的 pthreading 和 Ada 的 Tasking 之间存在一些根本区别。那是什么? Ada 任务是否未映射到 pthread? Ada 版本中是否有隐式循环调度?

循环加法循环 10 次大概不会花费很长时间,所以我尝试将循环计数增加到 100 以查看任务是否可以更频繁地中断。然后我只得到 200 和 100。

【问题讨论】:

  • 只是一个信息说明(仅供参考):Rust(和 SPARK)检测“数据竞争”,这是对可能导致错误值的非原子数据的多线程访问。 “竞争条件”是逻辑错误,允许您的数据处于您不期望的有效状态。 Rust 和 SPARK 都可能在编译时错过竞态条件(尽管 SPARK 确实有这样的优势,如果写得好,你的合约很可能会捕获它)。
  • 您的系统上有多少个处理器?现代系统通常有多个处理器,在这样的系统上,如果 >= 3 个处理器可用,您的每个任务通常将在自己的处理器上运行,并且不会有任务调度。在这种情况下,种族应该是明显的,并且值
  • 我记得有一次“有多少处理器?”是一个简单的问题。现在我们将处理器和内核区分开来,英特尔引入了超线程技术让我们保持警觉。回来 8080(我打字时我的办公桌抽屉里有一个),一切都被原谅了。无论如何,我有 4 个处理器和 2 个内核。我使用 Zoom 和其他多线程处理器来加载系统。我剩下的惊喜是,使用该程序的 C 和 Python 等效项,我得到的值通常在 10 到 20 之间,但有时低至 8。使用 Ada 版本,我似乎只能得到 10 或 20。跨度>
  • 我的第一个想法是编译器可能会展开您的循环,甚至可能将最终分配保存到 x 直到结束。如果你还没有,你可以尝试在调试模式下编译。

标签: ada


【解决方案1】:

使用 GNAT Community 2020,我使用 SPARK 获得以下诊断结果:

package threads with
   SPARK_Mode
is
   X : Natural := 0;
   pragma Volatile (X);

   task type AddTen with
   Global => (in_out => X);
end threads;

pragma Ada_2012;
package body threads with
SPARK_Mode
is

   ------------
   -- AddTen --
   ------------

   task body AddTen is
      Y : Integer;
   begin
      for I in 1 .. 10 loop
         Y := X + 1;
         X := Y;
      end loop;
   end AddTen;

end threads;

with Ada.Text_IO; use Ada.Text_IO;
with threads; use threads;

procedure Main with SPARK_Mode is
begin
   declare
      A : AddTen;
      B : AddTen;
   begin
      null;
   end;
   Put_Line(X'Image);
end Main;

检查所有来源时,我从 SPARK 收到以下消息:

gnatprove -PD:\Ada\Stack_Overflow\Race\race.gpr -j0 --mode=flow --ide-progress-bar -U Phase 1 of 2: generation of Global contracts ... threads.adb:14:15: volatile 对象不能出现在这个上下文中 (SPARK RM 7.1.3(12)) main.adb:12:13: volatile 对象不能出现在 此上下文(SPARK RM 7.1.3(11)) gnatprove:生成期间出错 全球合同

在我看来,SPARK 确实认为这种对 volatile 对象的使用是不恰当的。

当我简化程序时,将 volatile 更改为 atomic 并消除 SPARK 的使用,如下所示:

with Ada.Text_IO; use Ada.Text_IO;

procedure Main is
   X : Natural := 0;
   pragma Atomic (X);

   task type AddTen;
   task body AddTen is
      Y : Integer;
   begin
      for I in 1 .. 100 loop
         Y := X + 1;
         X := Y;
      end loop;
   end AddTen;
begin
   declare
      A, B : AddTen;
   begin
      null;
   end;
  
   Put_Line(X'Image);
end Main;

我总是得到 200 的结果。

请注意,在内部块中运行任务会导致主过程的外部块等待内部块完成,而内部块仅在两个任务都完成时才完成。

当我通过将循环上限更改为 10000 来强制执行更长的时间时,我会得到混合的数字,例如 15509、16318、15283、14555。

【讨论】:

  • 你(当然)是对的:我现在收到同样的错误信息。我在 John W McComick 和 Peter Chapin 的“使用 SPARK 构建高完整性应用程序”中发表评论说,“在撰写本文时可用的 SPARK 2014 版本 [2015] 不支持任何 Ada 任务功能。 ..”。
  • 是的,在循环中使用更大的数字可以让线程运行足够长的时间来获得调度操作。对于 C 程序,我不需要超过 10。我只运行了 100,000 次:10 -> 9 次 11 -> 1 次 12 -> 1 次 13 -> 1 次 15 -> 4 次 16 -> 2 次 17 -> 1 次 18 -> 1 次 19 -> 1 次 20 -> 99979 次
【解决方案2】:

没有循环调度,这不是默认的(它通常是优先级中的 FIFO),为什么任务会交换?那里没有调度点。 我应该补充一点,我更习惯于在单处理器 MCU 上使用 Ravenscar RTS 进行调度,所以我可能在这里搞错了!

这将导致每次 200,显然不是这种情况(顺便说一下,循环计数为 10_000_000 的结果非常相似!但我总是看到 10000000 的结果)。我想在主程序中delay可能会有一些调度影响。

我试过只用

pragma Task_Dispatching_Policy 
  (Round_Robin_Within_Priorities);

没有效果。

然后

Ada.Dispatching.Round_Robin.Set_Quantum 
  (System.Default_Priority,
   Ada.Real_Time.Microseconds (2));

这给出了错误

Round_Robin is not supported in this configuration

(配置为macOS/GCC 10.1.0)。

然后

pragma Atomic (x);

这确实会产生您所期望的差异。

欢迎解释!

【讨论】:

  • 关于调度点,我应该提到我在做测试时故意运行视频或更高优先级的东西。 Zoom 在这方面特别好/坏:当我在 Zoom 通话中进行演示时,我得到的值比使用任何其他程序都要广泛得多。运行演示所需的时间几乎是我不在 Zoom 上时的两倍。我假设 Zoom 会启动很多高优先级线程,而我在一台相当慢的笔记本电脑上运行。
  • 程序实际上是erroneous,见ARM 9.10(11) - 虽然这和ARM C.6(17)之间似乎有冲突!
  • 所以,如果我理解的话,根据 ARM 9.10(11) 我的程序是错误的(很好,因为它是)。根据应该在“运行时间之前”(即由编译器)检测到的“错误分类”。但它不是,直到 SPARK 被打开。
  • 在 Ada 中,“错误”是一个艺术术语。 ARM 1.1.5(10):“除了有界错误之外,语言规则还定义某些类型的错误会导致 错误执行。与有界错误一样,实现也不需要在执行之前检测到此类错误“很像 C 的“未定义行为”。
  • 是的,我刚刚试用了带有 System.Atomic_Operations.Modular_Arithmetic 的版本。持续工作会破坏问题!
猜你喜欢
  • 2016-11-16
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2019-04-29
  • 2016-02-02
  • 2013-11-14
  • 2021-07-30
相关资源
最近更新 更多