- Java锁的逻辑(结合对象头和ObjectMonitor)
- 还在用饼状图?来瞧瞧这些炫酷的百分比可视化新图形(附代码实现)⛵
- 自动注册实体类到EntityFrameworkCore上下文,并适配ABP及ABPVNext
- 基于Sklearn机器学习代码实战
在本篇文章当中主要给大家介绍在 OpenMP 当中 task 的实现原理,以及他调用的相关的库函数的具体实现。在本篇文章当中最重要的就是理解整个 OpenMP 的运行机制.
在本小节当中主要给大家分析一下编译器将 openmp 的 task construct 编译成什么样子,下面是一个 OpenMP 的 task 程序例子:
#include <stdio.h>
#include <omp.h>
int main()
{
#pragma omp parallel num_threads(4) default(none)
{
#pragma omp task default(none)
{
printf("Hello World from tid = %d\n", omp_get_thread_num());
}
}
return 0;
}
首先先捋一下整个程序被编译之后的执行流程,经过前面的文章的学习,我们已经知道了并行域当中的代码会被编译器编译成一个函数,关于这一点我们已经在前面的很多文章当中已经讨论过了,就不再进行复述。事实上 task construct 和 parallel construct 一样,task construct 也会被编译成一个函数,同样的这个函数也会被作为一个参数传递给 OpenMP 内部,被传递的这个函数可能被立即执行,也可能在函数 GOMP_parallel_end 被调用后,在到达同步点之前执行被执行(线程在到达并行域的同步点之前需要保证所有的任务都被执行完成)。整个过程大致如下图所示:
上面的 OpenMP task 程序对应的反汇编程序如下所示:
00000000004008ad <main>:
4008ad: 55 push %rbp
4008ae: 48 89 e5 mov %rsp,%rbp
4008b1: ba 04 00 00 00 mov $0x4,%edx
4008b6: be 00 00 00 00 mov $0x0,%esi
4008bb: bf db 08 40 00 mov $0x4008db,%edi
4008c0: e8 8b fe ff ff callq 400750 <GOMP_parallel_start@plt>
4008c5: bf 00 00 00 00 mov $0x0,%edi
4008ca: e8 0c 00 00 00 callq 4008db <main._omp_fn.0>
4008cf: e8 8c fe ff ff callq 400760 <GOMP_parallel_end@plt>
4008d4: b8 00 00 00 00 mov $0x0,%eax
4008d9: 5d pop %rbp
4008da: c3 retq
00000000004008db <main._omp_fn.0>:
4008db: 55 push %rbp
4008dc: 48 89 e5 mov %rsp,%rbp
4008df: 48 83 ec 10 sub $0x10,%rsp
4008e3: 48 89 7d f8 mov %rdi,-0x8(%rbp)
4008e7: c7 04 24 00 00 00 00 movl $0x0,(%rsp) # 参数 flags
4008ee: 41 b9 01 00 00 00 mov $0x1,%r9d # 参数 if_clause
4008f4: 41 b8 01 00 00 00 mov $0x1,%r8d # 参数 arg_align
4008fa: b9 00 00 00 00 mov $0x0,%ecx # 参数 arg_size
4008ff: ba 00 00 00 00 mov $0x0,%edx # 参数 cpyfn
400904: be 00 00 00 00 mov $0x0,%esi # 参数 data
400909: bf 15 09 40 00 mov $0x400915,%edi # 这里就是调用函数 main._omp_fn.1
40090e: e8 9d fe ff ff callq 4007b0 <GOMP_task@plt>
400913: c9 leaveq
400914: c3 retq
0000000000400915 <main._omp_fn.1>:
400915: 55 push %rbp
400916: 48 89 e5 mov %rsp,%rbp
400919: 48 83 ec 10 sub $0x10,%rsp
40091d: 48 89 7d f8 mov %rdi,-0x8(%rbp)
400921: e8 4a fe ff ff callq 400770 <omp_get_thread_num@plt>
400926: 89 c6 mov %eax,%esi
400928: bf d0 09 40 00 mov $0x4009d0,%edi
40092d: b8 00 00 00 00 mov $0x0,%eax
400932: e8 49 fe ff ff callq 400780 <printf@plt>
400937: c9 leaveq
400938: c3 retq
400939: 0f 1f 80 00 00 00 00 nopl 0x0(%rax)
从上面程序反汇编的结果我们可以知道,在主函数当中仍然和之前一样在并行域前后分别调用了 GOMP_parallel_start 和 GOMP_parallel_end,然后在两个函数之间调用并行域的代码 main._omp_fn.0 ,并行域当中的代码被编译成函数 main._omp_fn.0 ,从上面的汇编代码我们可以看到在函数 main._omp_fn.0 调用了函数 GOMP_task ,这个函数的函数声明如下所示:
void
GOMP_task (void (*fn) (void *), void *data, void (*cpyfn) (void *, void *),
long arg_size, long arg_align, bool if_clause, unsigned flags);
在这里我们重要解释一下部分参数,首先我们需要了解的是在 x86 当中的函数调用规约,这一点我们在前面的文章当中已经讨论过了,这里只是说明一下:
寄存器 | 含义 |
---|---|
rdi | 第一个参数 |
rsi | 第二个参数 |
rdx | 第三个参数 |
rcx | 第四个参数 |
r8 | 第五个参数 |
r9 | 第六个参数 |
根据上面的寄存器和参数的对应关系,在上面的汇编代码当中已经标注了对应的参数。在这些参数当中最重要的一个参数就是第一个函数指针,对应的汇编语句为 mov $0x400915,%edi,可以看到的是传入的函数的地址为 0x400915,根据上面的汇编程序可以知道这个地址对应的函数就是 main._omp_fn.1,这其实就是 task 区域之间被编译之后的对应的函数,从上面的 main._omp_fn.1 汇编程序当中也可以看出来调用了函数 omp_get_thread_num,这和前面的 task 区域当中代码是相对应的.
现在我们来解释一下其他的几个参数:
我们现在使用另外一个例子,来看看参数传递的变化.
#include <stdio.h>
#include <omp.h>
int main()
{
#pragma omp parallel num_threads(4) default(none)
{
int data = omp_get_thread_num();
#pragma omp task default(none) firstprivate(data) if(data > 100)
{
data = omp_get_thread_num();
printf("data = %d Hello World from tid = %d\n", data, omp_get_thread_num());
}
}
return 0;
}
上面的程序被编译之后对应的汇编程序如下所示:
00000000004008ad <main>:
4008ad: 55 push %rbp
4008ae: 48 89 e5 mov %rsp,%rbp
4008b1: 48 83 ec 10 sub $0x10,%rsp
4008b5: ba 04 00 00 00 mov $0x4,%edx
4008ba: be 00 00 00 00 mov $0x0,%esi
4008bf: bf df 08 40 00 mov $0x4008df,%edi
4008c4: e8 87 fe ff ff callq 400750 <GOMP_parallel_start@plt>
4008c9: bf 00 00 00 00 mov $0x0,%edi
4008ce: e8 0c 00 00 00 callq 4008df <main._omp_fn.0>
4008d3: e8 88 fe ff ff callq 400760 <GOMP_parallel_end@plt>
4008d8: b8 00 00 00 00 mov $0x0,%eax
4008dd: c9 leaveq
4008de: c3 retq
00000000004008df <main._omp_fn.0>:
4008df: 55 push %rbp
4008e0: 48 89 e5 mov %rsp,%rbp
4008e3: 48 83 ec 20 sub $0x20,%rsp
4008e7: 48 89 7d e8 mov %rdi,-0x18(%rbp)
4008eb: e8 80 fe ff ff callq 400770 <omp_get_thread_num@plt>
4008f0: 89 45 fc mov %eax,-0x4(%rbp)
4008f3: 83 7d fc 64 cmpl $0x64,-0x4(%rbp)
4008f7: 0f 9f c2 setg %dl
4008fa: 8b 45 fc mov -0x4(%rbp),%eax
4008fd: 89 45 f0 mov %eax,-0x10(%rbp)
400900: 48 8d 45 f0 lea -0x10(%rbp),%rax
400904: c7 04 24 00 00 00 00 movl $0x0,(%rsp) # 参数 flags
40090b: 41 89 d1 mov %edx,%r9d # 参数 if_clause
40090e: 41 b8 04 00 00 00 mov $0x4,%r8d # 参数 arg_align
400914: b9 04 00 00 00 mov $0x4,%ecx # 参数 arg_size
400919: ba 00 00 00 00 mov $0x0,%edx # 参数 cpyfn
40091e: 48 89 c6 mov %rax,%rsi # 参数 data
400921: bf 2d 09 40 00 mov $0x40092d,%edi # 这里就是调用函数 main._omp_fn.1
400926: e8 85 fe ff ff callq 4007b0 <GOMP_task@plt>
40092b: c9 leaveq
40092c: c3 retq
000000000040092d <main._omp_fn.1>:
40092d: 55 push %rbp
40092e: 48 89 e5 mov %rsp,%rbp
400931: 48 83 ec 20 sub $0x20,%rsp
400935: 48 89 7d e8 mov %rdi,-0x18(%rbp)
400939: 48 8b 45 e8 mov -0x18(%rbp),%rax
40093d: 8b 00 mov (%rax),%eax
40093f: 89 45 fc mov %eax,-0x4(%rbp)
400942: e8 29 fe ff ff callq 400770 <omp_get_thread_num@plt>
400947: 89 c2 mov %eax,%edx
400949: 8b 45 fc mov -0x4(%rbp),%eax
40094c: 89 c6 mov %eax,%esi
40094e: bf f0 09 40 00 mov $0x4009f0,%edi
400953: b8 00 00 00 00 mov $0x0,%eax
400958: e8 23 fe ff ff callq 400780 <printf@plt>
40095d: c9 leaveq
40095e: c3 retq
40095f: 90 nop
在上面的函数当中我们将 data 一个 4 字节的数据作为线程私有数据,可以看到给函数 GOMP_task 传递的参数参数的大小以及参数的内存对齐大小都发生来变化,从原来的 0 变成了 4,这因为 int 类型数据占 4 个字节.
在本小节当中主要谈论在 OpenMP 内部是如何实现 task 的,关于这一部分内容设计的内容还是比较庞杂,首先需要了解的是在 OpenMP 当中使用 task construct 的被称作显示任务(explicit task),这种任务在 OpenMP 当中会有两个任务队列(双向循环队列),将所有的任务都保存在这样一张列表当中,整体结构如下图所示:
在上图当中由同一个线程创建的任务为 child_task,他们之间使用 next_child 和 prev_child 两个指针进行连接,不同线程创建的任务之间可以使用 next_queue 和 prev_queue 两个指针进行连接.
任务的结构体描述如下所示:
struct gomp_task
{
struct gomp_task *parent; // 任务的父亲任务
struct gomp_task *children; // 子任务
struct gomp_task *next_child; // 下一个子任务
struct gomp_task *prev_child; // 上一个子任务
struct gomp_task *next_queue; // 下一个任务 (不一定是同一个线程创建的子任务)
struct gomp_task *prev_queue; // 上一个任务 (不一定是同一个线程创建的子任务)
struct gomp_task_icv icv; // openmp 当中内部全局设置使用变量的值(internal control variable)
void (*fn) (void *); // task construct 被编译之后的函数
void *fn_data; // 函数参数
enum gomp_task_kind kind; // 任务类型 具体类型如下面的枚举类型
bool in_taskwait; // 是否处于 taskwait 状态
bool in_tied_task; // 是不是在绑定任务当中
bool final_task; // 是不是最终任务
gomp_sem_t taskwait_sem; // 对象锁 用于保证线程操作这个数据的时候的线程安全
};
// openmp 当中的任务的状态
enum gomp_task_kind
{
GOMP_TASK_IMPLICIT,
GOMP_TASK_IFFALSE,
GOMP_TASK_WAITING,
GOMP_TASK_TIED
};
在了解完上面的数据结构之后我们来看一下前面的给 OpenMP 内部提交任务的函数 GOMP_task,其源代码如下所示:
/* Called when encountering an explicit task directive. If IF_CLAUSE is
false, then we must not delay in executing the task. If UNTIED is true,
then the task may be executed by any member of the team. */
void
GOMP_task (void (*fn) (void *), void *data, void (*cpyfn) (void *, void *),
long arg_size, long arg_align, bool if_clause, unsigned flags)
{
struct gomp_thread *thr = gomp_thread ();
// team 是 OpenMP 一个线程组当中共享的数据
struct gomp_team *team = thr->ts.team;
#ifdef HAVE_BROKEN_POSIX_SEMAPHORES
/* If pthread_mutex_* is used for omp_*lock*, then each task must be
tied to one thread all the time. This means UNTIED tasks must be
tied and if CPYFN is non-NULL IF(0) must be forced, as CPYFN
might be running on different thread than FN. */
if (cpyfn)
if_clause = false;
if (flags & 1)
flags &= ~1;
#endif
// 这里表示如果是 if 子句的条件为真的时候或者是孤立任务(team == NULL )或者是最终任务的时候或者任务队列当中的任务已经很多的时候
// 提交的任务需要立即执行而不能够放入任务队列当中然后在 GOMP_parallel_end 函数当中进行任务的取出
// 再执行
if (!if_clause || team == NULL
|| (thr->task && thr->task->final_task)
|| team->task_count > 64 * team->nthreads)
{
struct gomp_task task;
gomp_init_task (&task, thr->task, gomp_icv (false));
task.kind = GOMP_TASK_IFFALSE;
task.final_task = (thr->task && thr->task->final_task) || (flags & 2);
if (thr->task)
task.in_tied_task = thr->task->in_tied_task;
thr->task = &task;
if (__builtin_expect (cpyfn != NULL, 0))
{
// 这里是进行数据的拷贝
char buf[arg_size + arg_align - 1];
char *arg = (char *) (((uintptr_t) buf + arg_align - 1)
& ~(uintptr_t) (arg_align - 1));
cpyfn (arg, data);
fn (arg);
}
else
// 如果不需要进行数据拷贝则直接执行这个函数
fn (data);
/* Access to "children" is normally done inside a task_lock
mutex region, but the only way this particular task.children
can be set is if this thread's task work function (fn)
creates children. So since the setter is *this* thread, we
need no barriers here when testing for non-NULL. We can have
task.children set by the current thread then changed by a
child thread, but seeing a stale non-NULL value is not a
problem. Once past the task_lock acquisition, this thread
will see the real value of task.children. */
if (task.children != NULL)
{
gomp_mutex_lock (&team->task_lock);
gomp_clear_parent (task.children);
gomp_mutex_unlock (&team->task_lock);
}
gomp_end_task ();
}
else
{
// 下面就是将任务先提交到任务队列当中然后再取出执行
struct gomp_task *task;
struct gomp_task *parent = thr->task;
char *arg;
bool do_wake;
task = gomp_malloc (sizeof (*task) + arg_size + arg_align - 1);
arg = (char *) (((uintptr_t) (task + 1) + arg_align - 1)
& ~(uintptr_t) (arg_align - 1));
gomp_init_task (task, parent, gomp_icv (false));
task->kind = GOMP_TASK_IFFALSE;
task->in_tied_task = parent->in_tied_task;
thr->task = task;
// 这里就是参数拷贝逻辑 如果存在拷贝函数就通过拷贝函数进行参数赋值 否则使用 memcpy 进行
// 参数的拷贝
if (cpyfn)
cpyfn (arg, data);
else
memcpy (arg, data, arg_size);
thr->task = parent;
task->kind = GOMP_TASK_WAITING;
task->fn = fn;
task->fn_data = arg;
task->in_tied_task = true;
task->final_task = (flags & 2) >> 1;
// 在这里获取全局队列锁 保证下面的代码在多线程条件下的线程安全
// 因为在下面的代码当中会对全局的队列进行修改操作 下面的操作就是队列的一些基本操作啦
gomp_mutex_lock (&team->task_lock);
if (parent->children)
{
task->next_child = parent->children;
task->prev_child = parent->children->prev_child;
task->next_child->prev_child = task;
task->prev_child->next_child = task;
}
else
{
task->next_child = task;
task->prev_child = task;
}
parent->children = task;
if (team->task_queue)
{
task->next_queue = team->task_queue;
task->prev_queue = team->task_queue->prev_queue;
task->next_queue->prev_queue = task;
task->prev_queue->next_queue = task;
}
else
{
task->next_queue = task;
task->prev_queue = task;
team->task_queue = task;
}
++team->task_count;
gomp_team_barrier_set_task_pending (&team->barrier);
do_wake = team->task_running_count + !parent->in_tied_task
< team->nthreads;
gomp_mutex_unlock (&team->task_lock);
if (do_wake)
gomp_team_barrier_wake (&team->barrier, 1);
}
}
对于上述所讨论的内容大家只需要了解相关的整体流程即可,细节除非你是 openmp 的开发人员,否则事实上没有多大用,大家只需要了解大致过程即可,帮助你进一步深入理解 OpenMP 内部的运行机制.
但是需要了解的是上面的整个过程还只是将任务提交到 OpenMP 内部的任务队列当中,还没有执行,我们在前面谈到过在线程执行完并行域的代码会执行函数 GOMP_parallel_end 在这个函数内部还会调用其他函数,最终会调用函数 gomp_barrier_handle_tasks 将内部的所有的任务执行完成.
void
gomp_barrier_handle_tasks (gomp_barrier_state_t state)
{
struct gomp_thread *thr = gomp_thread ();
struct gomp_team *team = thr->ts.team;
struct gomp_task *task = thr->task;
struct gomp_task *child_task = NULL;
struct gomp_task *to_free = NULL;
// 首先对全局的队列结构进行加锁操作
gomp_mutex_lock (&team->task_lock);
if (gomp_barrier_last_thread (state))
{
if (team->task_count == 0)
{
gomp_team_barrier_done (&team->barrier, state);
gomp_mutex_unlock (&team->task_lock);
gomp_team_barrier_wake (&team->barrier, 0);
return;
}
gomp_team_barrier_set_waiting_for_tasks (&team->barrier);
}
while (1)
{
if (team->task_queue != NULL)
{
struct gomp_task *parent;
// 从任务队列当中拿出一个任务
child_task = team->task_queue;
parent = child_task->parent;
if (parent && parent->children == child_task)
parent->children = child_task->next_child;
child_task->prev_queue->next_queue = child_task->next_queue;
child_task->next_queue->prev_queue = child_task->prev_queue;
if (child_task->next_queue != child_task)
team->task_queue = child_task->next_queue;
else
team->task_queue = NULL;
child_task->kind = GOMP_TASK_TIED;
team->task_running_count++;
if (team->task_count == team->task_running_count)
gomp_team_barrier_clear_task_pending (&team->barrier);
}
gomp_mutex_unlock (&team->task_lock);
if (to_free) // 释放任务的内存空间 to_free 在后面会被赋值成 child_task
{
gomp_finish_task (to_free);
free (to_free);
to_free = NULL;
}
if (child_task) // 调用任务对应的函数
{
thr->task = child_task;
child_task->fn (child_task->fn_data);
thr->task = task;
}
else
return; // 退出 while 循环
gomp_mutex_lock (&team->task_lock);
if (child_task)
{
struct gomp_task *parent = child_task->parent;
if (parent)
{
child_task->prev_child->next_child = child_task->next_child;
child_task->next_child->prev_child = child_task->prev_child;
if (parent->children == child_task)
{
if (child_task->next_child != child_task)
parent->children = child_task->next_child;
else
{
/* We access task->children in GOMP_taskwait
outside of the task lock mutex region, so
need a release barrier here to ensure memory
written by child_task->fn above is flushed
before the NULL is written. */
__atomic_store_n (&parent->children, NULL,
MEMMODEL_RELEASE);
if (parent->in_taskwait)
gomp_sem_post (&parent->taskwait_sem);
}
}
}
gomp_clear_parent (child_task->children);
to_free = child_task;
child_task = NULL;
team->task_running_count--;
if (--team->task_count == 0
&& gomp_team_barrier_waiting_for_tasks (&team->barrier))
{
gomp_team_barrier_done (&team->barrier, state);
gomp_mutex_unlock (&team->task_lock);
gomp_team_barrier_wake (&team->barrier, 0);
gomp_mutex_lock (&team->task_lock);
}
}
}
}
在本篇文章当中主要给大家介绍了,OpenMP 内部对于任务的处理流程,这其中的细节非常复杂,大家只需要了解它的整个工作流程即可,这已经能够帮助大家理清楚整个 OpenMP 内部是如何对任务进行处理的,如果大家感兴趣可以自行研读源程序.
更多精彩内容合集可访问项目: https://github.com/Chang-LeHung/CSCore 。
关注公众号:一无是处的研究僧,了解更多计算机(Java、Python、计算机系统基础、算法与数据结构)知识.
最后此篇关于OpenMPtaskconstruct实现原理以及源码分析的文章就讲到这里了,如果你想了解更多关于OpenMPtaskconstruct实现原理以及源码分析的内容请搜索CFSDN的文章或继续浏览相关文章,希望大家以后支持我的博客! 。
我刚刚继承了一个旧的 PostgreSQL 安装,需要进行一些诊断以找出该数据库运行缓慢的原因。在 MS SQL 上,您可以使用 Profiler 等工具来查看正在运行的查询,然后查看它们的执行计划。
将目标从Analytics(分析)导入到AdWords中,然后在Analytics(分析)中更改目标条件时,是否可以通过更改将目标“重新导入”到AdWords,还是可以自动选择? 最佳答案 更改目标值
我正在使用google analytics api来获取数据。我正在获取数据,但我想验证两个参数,它们在特定日期范围内始终为0。我正在获取['ga:transactions']和['ga:goalCo
我使用Google API从Google Analytics(分析)获取数据,但指标与Google Analytics(分析)的网络界面不同。 即:我在2015年3月1日获得数据-它返回综合浏览量79
我在我的Web应用程序中使用sammy.js进行剔除。我正在尝试向其中添加Google Analytics(分析)。我很快找到了following plugin来实现页面跟踪。 我按照步骤操作,页面如
当使用 Xcode 分析 (product>analyze) 时,有没有办法忽略给定文件中的任何错误? 例如编译指示之类的? 我们只想忽略第三方代码的任何警告,这样当我们的代码出现问题时,它对我们
目录 EFK 1. 日志系统 2. 部署ElasticSearch 2.1 创建handless服务 2.2 创建s
关闭。这个问题不满足Stack Overflow guidelines .它目前不接受答案。 想改善这个问题吗?更新问题,使其成为 on-topic对于堆栈溢出。 7年前关闭。 Improve thi
GCC/G++ 是否有可用于输出分析的选项? 能够比较以前的代码与新代码之间的差异(大小、类/结构的大小)将很有用。然后可以将它们与之前的输出进行比较以进行比较,这对于许多目的都是有用的。 如果没有此
我正在浏览 LYAH,并一直在研究处理列表时列表理解与映射/过滤器的使用。我已经分析了以下两个函数,并包含了教授的输出。如果我正确地阅读了教授的内容,我会说 FiltB 的运行速度比 FiltA 慢很
在 MySQL 中可以使用 SET profiling = 1; 设置分析 查询 SHOW PROFILES; 显示每个查询所用的时间。我想知道这个时间是只包括服务器的执行时间还是还包括将结果发送到前
我用 Python 编写了几个用于生成阶乘的模块,我想测试运行时间。我找到了一个分析示例 here我使用该模板来分析我的模块: import profile #fact def main():
前几天读了下mysqld_safe脚本,个人感觉还是收获蛮大的,其中细致的交代了MySQL数据库的启动流程,包括查找MySQL相关目录,解析配置文件以及最后如何调用mysqld程序来启动实例等,有着
上一篇:《人工智能大语言模型起源篇,低秩微调(LoRA)》 (14)Rae 和同事(包括78位合著者!)于2022年发表的《Scaling Language Models: Methods, A
1 内网基础 内网/局域网(Local Area Network,LAN),是指在某一区域内有多台计算机互联而成的计算机组,组网范围通常在数千米以内。在局域网中,可以实现文件管理、应用软件共享、打印机
1 内网基础 内网/局域网(Local Area Network,LAN),是指在某一区域内有多台计算机互联而成的计算机组,组网范围通常在数千米以内。在局域网中,可以实现文件管理、应用软件共享、打印机
我有四列形式的数据。前三列代表时间,value1,value 2。第四列是二进制,全为 0 或 1。当第四列中对应的二进制值为0时,有没有办法告诉excel删除时间、值1和值2?我知道这在 C++ 或
我正在运行一个进行长时间计算的 Haskell 程序。经过一些分析和跟踪后,我注意到以下内容: $ /usr/bin/time -v ./hl test.hl 9000045000050000 Com
我有一个缓慢的 asp.net 程序正在运行。我想分析生产服务器以查看发生了什么,但我不想显着降低生产服务器的速度。 一般而言,配置生产盒或仅本地开发盒是标准做法吗?另外,您建议使用哪些程序来实现这一
我目前正在尝试分析 Haskell 服务器。服务器永远运行,所以我只想要一个固定时间的分析报告。我尝试只运行该程序 3 分钟,然后礼貌地要求它终止,但不知何故,haskell 分析器不遵守术语信号,并
我是一名优秀的程序员,十分优秀!