在 Linux 上利用 C++ 多线程支持开发 MetaTrader 5 概念验证 DLL

Wasin Thonkaew | 6 六月, 2023

概述

Linux 拥有充满活力的开发生态系统,以及良好的软件开发工效。

它对许多用户极具吸引力,譬如那些喜欢命令行操控,通过软件包管理器轻松安装应用程序,操作系统本身不是黑盒,故您能够深入了解其内部,几乎可以针对所有子系统进行配置,开箱即用的基本开发工具,适合软件开发的灵活和简化的环境,等等。

它的范围从 PC 桌面最终用户,到云解决方案(例如 VPS),或云服务提供商(如 AWS,谷歌云)。

故此,我坚信这里有一些开发人员希望守护他们选择的操作系统,但亦希望能够为 Windows 用户开发和交付产品。 当然,产品必须能够跨平台无缝运行。

通常,MetaTrader 5 开发人员只需利用 MQL5 编程语言来开发他们的指标/智能交易或相关产品,然后在市场上发布给最终用户,而无需担心基于哪个操作系统。 他们可以依靠 MT5 的 IDE 来负责编译和构建交付前的(.EX5)可执行文件(前提是他们知道如何在 Linux 上启动 MetaTrader 5)。 但是,当开发人员需要将自定义解决方案开发为共享库(DLL),以便进一步扩展和提供仅靠 MQL5 编程语言无法提供的其它服务时,他们将不得不花费更多的时间和精力来寻求交叉编译的解决方案、发现漏洞和最佳实践、熟悉工具,等等。


这些就是本文出现的原因。 依靠涉及交叉编译解决方案,并利用 C++ 具有的多线程能力构建 DLL,这两者相结合,至少可作为开发人员进一步扩展的基础。 我希望它能帮助您在心仪的操作系统(即 Linux)上继续开发 MetaTrader 5 相关产品。

本文是为哪些人准备的

我假设阅读本文的读者都已经有一些通过命令行与 Linux 交互的经验,并且拥有在 Linux 上编译和构建 C++ 源代码的一般概念。

无论如何,本文是为那些想要探索开发步骤和工作流程的人员准备的,以便能够开发在 Linux 以及 Windows 上都能工作的,具有多线程功能的 DLL。 扩展线程编程选项,不仅内置 OpenCL,而且具有 C++ 多线程功能,可与一些紧密基于它的其它系统集成,从而扩展线程编程选项。 

所用系统 & 软件

游戏计划

我们将基于以下计划

  1. 了解 Wine
  2. 了解 Mingw
  3. Mingw 的线程实现
    1. POSIX (pthread)
    2. Win32 (经由 mingw-std-threads)
  4. 准备 Linux 开发机器
    1. Wine 安装
    2. MetaTrader 5 安装
    3. Mingw 安装
    4. (可选)mingw-std-threads 安装
  5. 概念验证,开发阶段 I — DLL(C++ 多线程支持)
  6. 概念验证,开发阶段 II — 调用 DLL 的 MQL5 代码
  7. 在 Windows 系统上测试
  8. Mingw 线程实现的简单基准测试


Wine

Wine 是缩写 (技术上说是 递归 回旋缩写) "Wine is Not an Emulator"。 它并非任何处理器或目标硬件的模拟器。 取而代之,它是在非 Windows 操作系统上运行的 win32 API 的包装器。

Wine 引入了另一个抽象层,该抽象层拦截来自非 Windows 系统上的用户对 win32 API 的调用,然后将其路由到 Wine 的内部,然后其处理和行为请求,就如同(或几乎以相同的方式)在 Windows 上的行为一样。 

这意味着调用这些 win32 API 的 Wine 操作是基于 POSIX API 运行。 读者也许体验过 Wine 软件,却不知道它们何时在 Linux 上启动此类 Windows 软件,甚至可以在 Linux 上玩 steam 游戏,因为其运行时基于称为 Proton 的 Wine 变体。

这允许灵活地测试或使用 Windows 软件,其替代方案能否在 Linux 上可用。

通常,当您想通过 Wine 运行基于 Windows 的应用程序时,您将执行以下命令

wine windows_app.exe

或者,如果您想与特定 Wine 环境前缀关联,来运行应用程序,您也可以这样做

WINEPREFIX=~/.my_mt5_env wine mt5_terminal64.exe


Mingw

Mingw 代表 "Minimalist GNU for Windows"。 它是 GNU 编译器集合(GCC)及其工具链的端口,用于在 Linux 上编译针对 Windows 的 C/C++ 和其它一些编程语言。

功能上,在 GCC 和 Mingw 中编译标志/选项一致,且用法相似,因此用户可以轻松地将他们现有的知识从 GCC 转换为 Mingw。 另请注意,GCC 在编译标志/选项上也与 Clang 非常相似。 故此,您可以看到用法无缝转换,用户能够保留他们的知识,能够将用户群扩展到 Windows 系统上。

请参阅以下比较表以了解差异。

编译器 命令行
GCC
g++ -shared -std=c++17 -fPIC -o libexample.so example.cpp -lpthread
Mingw
x86_64-w64-mingw32-g++-posix -shared -std=c++17 -fPIC -o example.dll example.cpp -lpthread

  • 编译 C++ 源代码,并构建可执行库
编译器 命令行
GCC
g++ -std=c++17 -I. -o main.out main.cpp -L. -lexample
Mingw 
x86_64-w64-mingw32-g++-posix -std=c++17 -I. -o main.exe main.cpp -L. -lexample

读者能注意到差异很小。 编译标志非常相似,基本相同。 仅在于我们所用的编译器二进制文件有差异,并以此来编译和构建我们需要的所有东西。

有 3 种变体可供使用,其会引发线程实现的主题,我们将在下一节中对其进行解释。

  1. x86_64-w64-mingw32-g++
    它别名为  x86_64-w64-mingw32-g++-win32

  2. x86_64-w64-mingw32-g++-posix
    其二进制可执行文件打算与 pthread 配合使用。

  3. x86_64-w64-mingw32-g++-win32
    其二进制可执行文件旨在与 win32 API 线程模型配合使用。 它别名为 86_64-w64-mingw32-g++

此外,还有其它几个工具前缀为 

x86_64-w64-mingw32-...

一些示例如下

此外还有 x86_64-w64-mingw32-gcc-nm-posix和 x86_64-w64-mingw32-gcc-nm-win32 针对某些版本的变体。

Mingw 线程实现

从上一章节中,我们现在知道 Mingw 提供了 2 种线程实现变体。
  1. POSIX (pthread)
  2. Win32

为什么我们需要如此关注这一点? 我能想到的两个原因

  1. 出于安全性和兼容性
    当您的代码有潜力同时使用 C++ 多线程能力(例如 std::thread std::promise,等等),以及操作系统的原生多线程支持(例如 win32 API 支持的 CreateThread()  ,和 POSIX API 支持的 pthread_create() ,如此最好坚持只用其中一个 API,而不必换到另一个。

    无论如何,我们不太可能在代码里把来自 C++ 的多线程功能,和操作系统本身的支持混合在一起,除非出现非常特殊的情况,即操作系统支持 API 的提供了更多 C++ 无法提供的功能。 故此,最好保持一致性,并针对两者运用相同的线程模型。
    如果我们使用 Mingw 的 pthread 实现,那么尽量不要使用 win32 API 的线程功能。 同样,如果我们使用 Mingw 的 win32 线程实现(从现在开始简称 “win32 线程”),那么最好避免使用 OS 的 pthread API。
  2. 性能 (稍后请参阅 Mingw 线程实现的简单基准测试章节)
    当然,用户想要一个低延迟的多线程解决方案。 在某些情况下更快的执行解决方案,可能是某些用户的选择之一。

我们将首先开发概念验证 DLL 和测试程序,然后再针对两个线程实现进行基准测试。

针对该项目,我们提供的可移植代码可采用 pthread 或 win32 线程,我们的构建系统能够轻松地相互切换。
在使用 win32 线程的情况下,我们需要从 mingw-std-threads 项目中安装头文件,我们会在其中指导读者下一步如何操作。


准备 Linux 开发计算机

在直接进入编码部分之前,我们需要先安装所需的软件。

Wine 安装

执行以下命令以安装 Wine devel 软件包。

sudo apt install winehq-devel

然后用以下命令检查它是否能正常工作,

wine --version

其输出应是这样的

wine-8.0-rc3


MetaTrader 5 安装

大多数用户早在构建版本3550之前就已经安装了MetaTrader 5,而该构建版本存在崩溃问题。 为了使用 winehq-devel 软件包来解决问题,并能够启动 MetaTrader 5,我们不能直接使用如同如何在 Linux 上安装平台所示的官方安装脚本。
最好能自己执行命令,因为直接执行官方安装脚本会用 stable 版本软件包覆盖我们的 Wine。

我已经撰写了指南 MT5 构建 3550 在 Linux 上通过 Wine 启动时崩溃。 如何解决?  该篇文章应该涵盖已安装 Wine stable 版本软件包用户的所有情况,或者想要从 devel 包重新开始的用户。

如此之后,尝试再次通过 Wine 启动 MetaTrader 5。 看看是否有任何问题。

注意

官方安装脚本将在 ~/.mt5 处创建一个 Wine 环境(称为前缀)。 在您的 ~/.bash_aliases 中包含以下行可能很方便,这样您就可以轻松启动 MetaTrader 5。

alias mt5trader="WINEPREFIX=~/.mt5 wine '/home/haxpor/.mt5/drive_c/Program Files/MetaTrader 5/terminal64.exe'"

然后执行命令 source

source ~/.bash_aliases

最后执行以下命令来启动 MT5,其调试输出将显示在终端上。

mt5trader

按这种方式启动 MetaTrader 5 将允许我们以后轻松地从概念验证应用程序中查看调试日志,而不会令我们的代码变得复杂。

Mingw 安装

执行以下命令以安装 Mingw。

sudo apt install mingw-w64

这会将一堆工具的集合安装到您的系统中,其中这些工具的前缀为 x86_64-w64-mingw32- 大多数情况下,我们将使用 x86_64-w64-mingw32-g++-posix (或 x86_64-w64-mingw32-win32 如若使用 win86 线程)。

mingw-std-threads 安装

mingw-std-threads 是一个将 Mingw 的 win32 线程粘合到 Linux 上的项目。 它仅提供头文件作为插入式解决方案。 因此,安装很简单,只需要将其头文件放入系统的包含路径之中。

请遵照以下步骤进行安装。

首先,将 git 存储库克隆到您的系统当中。

git clone git@github.com:Kitware/CMake.git

然后创建一个目录,以便将其头文件保存在系统的包含路径处。

sudo mkdir /usr/x86_64-w64-mingw32/include/mingw-std-threads

最后,将所有头文件(.h)从克隆项目的目录里复制到此类新创建的目录。

cp -av *.h /usr/x86_64-w64-mingw32/include/mingw-std-threads/

这就是全部。 然后在代码中,如果我们决定使用 win32 线程,对于一些与多线程功能相关的头文件(例如线程、同步原语等),我们需要以替换后的名称从正确路径包含它。 有关详尽的清单,请参阅下表。

C++11 多线程头文件包含 mingw-std-threads 头文件包含更改为
#include <mutex>
#include <mingw-std-threads/mingw.mutex.h>
#include <thread>
#include <mingw-std-threads/mingw.thread.h>
#include <shared_mutex>
#include <mingw-std-threads/mingw.shared_mutex.h>
#include <future>
#include <mingw-std-threads/mingw.future.h>

#include <condition_variable>
#include <mingw-std-threads/mingw.condition_variable.h>


概念验证,开发阶段 I — DLL(C++ 多线程支持)

现在到了开始编写代码的时候了。

我们的目标是实现一个概念验证 DLL 解决方案,该解决方案能够使用 C++11 标准库中的多线程功能,如此读者可以领会其思想,并进一步扩展。

以下是我们的库和应用程序结构。

项目结构

采用的 C++ 标准

我们将采用 C++17 标准,尽管我们将主要使用 C++11 中的功能,但一些例如代码注释的属性(如 [[nodiscard]] )需要 C++17。

DLL

example.h

#pragma once

#ifdef WINDOWS
        #ifdef EXAMPLE_EXPORT
                #define EXAMPLE_API __declspec(dllexport)
        #else
                #define EXAMPLE_API __declspec(dllimport)
        #endif
#else
        #define EXAMPLE_API
#endif

// we have to use 'extern "C"' in order to export functions from DLL to be used
// in MQL5 code.
// Using 'namespace' or without such extern won't make it work for MQL5 code, it
// won't be able to find such functions.
extern "C" {
	/**
	 * Add two specified number together.
	 */
        EXAMPLE_API [[nodiscard]] int add(int a, int b) noexcept;

	/**
	 * Subtract two specified number.
	 */
        EXAMPLE_API [[nodiscard]] int sub(int a, int b) noexcept;

	/**
	 * Get the total number of hardware's concurrency.
	 */
	EXAMPLE_API [[nodiscard]] int num_hardware_concurrency() noexcept;

	/**
	 * Sum all elements from specified array for number of specified elements.
	 * The computation will be done in a single thread linearly manner.
	 */
	EXAMPLE_API [[nodiscard]] int single_threaded_sum(const int arr[], int num_elem);

	/**
	 * Sum all elements from specified array for number of specified elements.
	 * The computation will be done in a multi-thread.
	 *
	 * This version is suitable for processor that bases on MESI cache coherence
	 * protocol. It won't make a copy of input array of data, but instead share
	 * it among all threads for reading purpose. It still attempt to write both
	 * temporary and final result with minimal number of times thus minimally
	 * affect the performance.
	 */
	EXAMPLE_API [[nodiscard]] int multi_threaded_sum_v2(const int arr[], int num_elem);
};

#pragma once 虽然不是 C++ 标准的一部分,但它得到了 GCC 的支持,因此也支持 Mingw。 这是一种灵活且更简洁的方式来防止包含重复的头文件。 如果不使用此类指令,用户则需同时使用 #ifdef 和 #define,并且需要确保每个定义对每个头文件都有唯一的名称。 这可能很耗时。

我们 #ifdef WINDOWS 来保护定义声明 EXAMPLE_API。 这允许我们能够使用 Mingw 和本机 Linux 系统进行编译。 故此,每当我们针对共享库进行交叉编译时,我们都会将 -DWINDOWS-DEXAMPLE_EXPORT 添加到编译标志当中,否则如果我们只针对测试主程序进行编译,那么我们可以删除 -DEXAMPLE_EXPORT

__declspec(dllexport) 是从 DLL 导出函数的指令

__declspec(dllimport) 是从 DLL 导入函数的指令

编译需要上述两个,以便在 Windows 上使用 DLL。 对于非 Windows 系统,我们不需要它们,但仍需要交叉编译。 因此,如果没有定义用于 Linux 编译的 WINDOWS,则 EXAMPLE_API 为空。

接下来,是函数签名中技术含量最重的部分。 函数签名需要与 C(编程语言)调用约定兼容。 该 extern "C" 将防止函数签名被擅改为 C++ 调用约定。

我们不能将函数签名包装在命名空间中,也不能将它们声明为自由函数,因为 MQL5 代码在以后使用 DLL 时将无法找到这些签名。

对于 num_hardware_concurrency(),它将返回实现支持的并发线程数量。 例如,我使用 6 核处理器,每个内核有 2 个线程,因此它实际上有 12 个线程可以并发工作。 就我的情况,它将返回 12。

single_threaded_sum() multi_threaded_sum_v2() 两者都是我们概念验证应用程序的主要示例,它们展示了多线程的好处,并比较了两者之间的性能。

example.cpp

#include "example.h"

#ifdef USE_MINGW_STD_THREAD
        #include <mingw-std-threads/mingw.thread.h>
#else
        #include <thread>
#endif

#include <iostream>
#include <vector>
#include <chrono>
#include <numeric>
#include <atomic>

#ifdef ENABLE_DEBUG
#include <cstdarg>
#endif

#ifdef ENABLE_DEBUG
const int LOG_BUFFER_SIZE = 2048;
char log_buffer[LOG_BUFFER_SIZE];

inline void DLOG(const char* ctx, const char* format, ...) {
        va_list args;
        va_start(args, format);
        std::vsnprintf(log_buffer, LOG_BUFFER_SIZE-1, format, args);
        va_end(args);

        std::cout << "[DEBUG] [" << ctx << "] " << log_buffer << std::endl;
}
#else
        #define DLOG(...)
#endif

EXAMPLE_API int add(int a, int b) noexcept {
        return a + b;
}

EXAMPLE_API int sub(int a, int b) noexcept {
        return a - b;
}

EXAMPLE_API int num_hardware_concurrency() noexcept {
        return std::thread::hardware_concurrency();
}

EXAMPLE_API int single_threaded_sum(const int arr[], int num_elem) {
        auto start = std::chrono::steady_clock::now();

        int local_sum = 0;
        for (int i=0; i<num_elem; ++i) {
                local_sum += arr[i];
        }

        std::chrono::duration<double, std::milli> exec_time = std::chrono::steady_clock::now() - start;
        std::cout << "elapsed time: " << exec_time.count() << "ms" << std::endl;
        return local_sum;
}

EXAMPLE_API int multi_threaded_sum_v2(const int arr[], int num_elem) {
        auto start = std::chrono::steady_clock::now();

        std::vector<std::pair<int, int>> arr_indexes;

        const int num_max_threads = std::thread::hardware_concurrency() == 0 ? 2 : std::thread::hardware_concurrency();
        const int chunk_work_size = num_elem / num_max_threads;

        std::atomic<int> shared_total_sum(0);

        // a lambda function that accepts input vector by reference
        auto worker_func = [&shared_total_sum](const int* arr, std::pair<int, int> indexes) {
                int local_sum = 0;
                for (int i=indexes.first; i<indexes.second; ++i) {
                        local_sum += arr[i];
                }
                shared_total_sum += local_sum;
        };

        DLOG("multi_threaded_sum_v2", "chunk_work_size=%d", chunk_work_size);
        DLOG("multi_threaded_sum_v2", "num_max_threads=%d", num_max_threads);

        std::vector<std::thread> threads;
        threads.reserve(num_max_threads);

        for (int i=0; i<num_max_threads; ++i) {
                int start = i * chunk_work_size;
                // also check if there's remaining to piggyback works into the last chunk
                int end = (i == num_max_threads-1) && (start + chunk_work_size < num_elem-1) ? num_elem : start+chunk_work_size;
                threads.emplace_back(worker_func, arr, std::make_pair(start, end));
        }

        DLOG("multi_threaded_sum_v2", "thread_size=%d", threads.size());

        for (auto& th : threads) {
                th.join();
        }

        std::chrono::duration<double, std::milli> exec_time = std::chrono::steady_clock::now() - start;
        std::cout << "elapsed time: " << exec_time.count() << "ms" << std::endl;

        return shared_total_sum;
}

以上是完整的代码,但为了便于理解,我们单独剖析每个部分。 下面将详细解释每个代码模块。

它们都可在 pthread 和 win32 线程之间切换。

#include "example.h"

#ifdef USE_MINGW_STD_THREAD
        #include <mingw-std-threads/mingw.thread.h>
#else
        #include <thread>
#endif

依据此设置,我们可以与我们的构建系统很好地集成,以便在 pthread 或 win32 线程和链接之间切换。 在编译标志中添加 -DUSE_MINGW_STD_THREAD,以便在我们进行交叉编译时使用 win32 线程。

实现简单的接口,直截了当。

EXAMPLE_API int add(int a, int b) noexcept {
        return a + b;
}

EXAMPLE_API int sub(int a, int b) noexcept {
        return a - b;
}

EXAMPLE_API int num_hardware_concurrency() noexcept {
        return std::thread::hardware_concurrency();
}

add() sub() 简单易懂。 对于 num_hardware_concurrency(),我们需要包含头文件 <thread> 以便调用 std::thread::hardware_concurrency()

调式日志实用程序函数。

#ifdef ENABLE_DEBUG
#include <cstdarg>
#endif

#ifdef ENABLE_DEBUG
const int LOG_BUFFER_SIZE = 2048;
char log_buffer[LOG_BUFFER_SIZE];

inline void DLOG(const char* ctx, const char* format, ...) {
        va_list args;
        va_start(args, format);
        std::vsnprintf(log_buffer, LOG_BUFFER_SIZE-1, format, args);
        va_end(args);

        std::cout << "[DEBUG] [" << ctx << "] " << log_buffer << std::endl;
}
#else
        #define DLOG(...)
#endif

通过在编译标志中添加 -DENABLE_DEBUG<,我们可以将调试日志打印到控制台上。 这就是为什么我建议通过命令行启动 MetaTrader 5,这样我们就可以相应地调试我们的程序。 若我们没有这样定义时,DLOG() 没有任何意义,它不会对我们的代码产生任何影响,无论是在执行速度方面,还是在共享库、或可执行的二进制文件大小方面。 这很不错。

DLOG() 的设计灵感来自 Android 开发,因为通常会有一个上下文字符串(来自任何组件调试日志),在这种情况下是 ctx,然后是调试日志字符串。

单线程求和函数的实现。

EXAMPLE_API int single_threaded_sum(const int arr[], int num_elem) {
        auto start = std::chrono::steady_clock::now();

        int local_sum = 0;
        for (int i=0; i<num_elem; ++i) {
                local_sum += arr[i];
        }

        std::chrono::duration<double, std::milli> exec_time = std::chrono::steady_clock::now() - start;
        std::cout << "elapsed time: " << exec_time.count() << "ms" << std::endl;
        return local_sum;
}

它模拟使用 MQL5 代码的实际用法。 想象一下这样的情形,MQL5 将一些数据作为数组发送给 DLL 函数,目的是让 DLL 计算一些东西,并将结果返回给之前的 MQL5 代码。
就是如此。 但是对于此函数,它会线性迭代指定输入数组中的所有元素,按 num_elem 指定的总数逐一获取元素。

该代码还通过使用 std::chrono 库来计算运行时间,从而对执行的总时间进行基准测试。 请注意,我们所用的 std::chrono::steady_clock 是一个单调时钟,可不受系统时钟调整影响的情况下一直向前移动。 它完全适用于测量时间间隔。

多线程求和函数的实现。

EXAMPLE_API int multi_threaded_sum_v2(const int arr[], int num_elem) {
        auto start = std::chrono::steady_clock::now();

        std::vector<std::pair<int, int>> arr_indexes;

        const int num_max_threads = std::thread::hardware_concurrency() == 0 ? 2 : std::thread::hardware_concurrency();
        const int chunk_work_size = num_elem / num_max_threads;

        std::atomic<int> shared_total_sum(0);

        // a lambda function that accepts input vector by reference
        auto worker_func = [&shared_total_sum](const int arr[], std::pair<int, int> indexes) {
                int local_sum = 0;
                for (int i=indexes.first; i<indexes.second; ++i) {
                        local_sum += arr[i];
                }
                shared_total_sum += local_sum;
        };

        DLOG("multi_threaded_sum_v2", "chunk_work_size=%d", chunk_work_size);
        DLOG("multi_threaded_sum_v2", "num_max_threads=%d", num_max_threads);

        std::vector<std::thread> threads;
        threads.reserve(num_max_threads);

        for (int i=0; i<num_max_threads; ++i) {
                int start = i * chunk_work_size;
                // also check if there's remaining to piggyback works into the last chunk
                int end = (i == num_max_threads-1) && (start + chunk_work_size < num_elem-1) ? num_elem : start+chunk_work_size;
                threads.emplace_back(worker_func, arr, std::make_pair(start, end));
        }

        DLOG("multi_threaded_sum_v2", "thread_size=%d", threads.size());

        for (auto& th : threads) {
                th.join();
        }

        std::chrono::duration<double, std::milli> exec_time = std::chrono::steady_clock::now() - start;
        std::cout << "elapsed time: " << exec_time.count() << "ms" << std::endl;

        return shared_total_sum;
}

请注意,它被标记为 v2,是出于历史原因,我将其保留。 简而言之,对于采用 MESI 缓存一致性协议的现代处理器,没有必要将数据集的副本馈送到每个线程当中,因为 MESI 会将这样的缓存行打上标记,并在多个线程之间共享,且不会浪费任何 CPU 周期来发送信号,及等待响应。 我在之前的 v1 实现里需全力以赴地制作数据集的副本,并将其馈送到每个线程之中。 但是根据所提到的现代处理器已经使用 MESI 的原因,现在已无必要在源代码中包含这种尝试。 v1 比 v2 至少慢 2-5 倍。

请注意 worker_func 这是一个 lambda 函数,它处理原始数据数组,并操控所用数据范围(开始和结束索引对)。 它在循环中的把所有元素汇总到一个局部变量之中,以避免假共享问题,该问题可能会显著降低性能,然后最终累加所有原子方式的线程到共享合计变量。 它使用 std::atomic 来助力线程安全。 此种类共享合计变量的倍数需要修改到足够少,才不会对性能产生重大影响。 平衡实现的实施和速度增益是一条要走的途径。

我们计算拆分工作需要多少线程,因此稍后就会知道每个线程的工作范围。 请注意,std::hardware_concurrency() 可以返回 0,这意味着它可能无法确定线程数量,因此我们也需处理这种情况,并回退到 2。

接下来,我们创建一个线程向量。 保留其容量 num_max_threads 。然后迭代计算要处理的每个线程的数据集范围。 请注意,对于最后一个线程,它将获取所有剩余数据,因为全部要完成处理的元素数量可能不会被计算线程数量整除。

重要的是,我们加入了所有线程。 对于更复杂的情况,我们也许需要异步环境,这样就不会被 MQL5 代码等待结果阻塞。 据此,我们通常调用 std::future,它是所有 std::async、std:::promisestd::packaged_task 的基础。因此,我们通常至少有 2 个接口,一个用于发出请求,将数据从 MQL5 代码发送到 DLL 计算,而不会阻塞;另一个按需接收此类请求的结果,此时,它将阻塞 MQL5 代码的调用。 我也许会在以后的文章中写点有关内容。

此外,在此过程中,我们可以使用 DLOG() 打印一些调试状态。 这对调试很有帮助。

接下来,我们实现在本机 Linux 上运行的可移植主测试程序,并通过 Wine 交叉编译环境。

main.cpp

#include "example.h"
#include <iostream>
#include <cassert>
#include <vector>
#include <memory>

int main() {
        int res = 0;

        std::cout << "--- misc ---\n";
        res = add(1,2);
        std::cout << "add(1,2): " << res << std::endl;
        assert(res == 3);

	res = 0;

        res = sub(2,1);
        std::cout << "sub(2,1): " << res << std::endl;
        assert(res == 1);

	res = 0;

        std::cout << "hardware concurrency: " << num_hardware_concurrency() << std::endl;
        std::cout << "--- end ---\n" << std::endl;

        std::vector<int> arr(1000000000, 1);

        std::cout << "--- single-threaded sum(1000M) ---\n";
        res = single_threaded_sum(arr.data(), arr.size());
        std::cout << "sum: " << res << std::endl;
        assert(res == 1000000000);
        std::cout << "--- end ---\n" << std::endl;
        
        res = 0;

        std::cout << "--- multi-threaded sum_v2(1000M) ---\n";
        res = multi_threaded_sum_v2(arr.data(), arr.size());
        std::cout << "sum: " << res << std::endl;
        assert(res == 1000000000);
        std::cout << "--- end ---" << std::endl;

        return 0;
}

正常情况下,我们要包含 example.h 头文件,以便能够调用这些已实现的接口。 我们还调用 assert() 验证结果是否正确。

接下来,我们构建共享库(作为 Linux 本机的 libexample.so 库),和主测试程序 main.out。 不是首先使用构建系统,而是通过命令行执行。 稍后我们将通过 Makefile 实现正确的构建系统。
在进行交叉编译之前,我们首先在 Linux 本机上测试它。

执行以下命令以构建要作为 libexample.so 输出的共享库。

$ g++ -shared -std=c++17 -Wall -Wextra -fno-rtti -O2 -I. -fPIC -o libexample.so example.cpp -lpthread

每个标志的说明如下

标志 说明
-shared 指令编译器构建共享库
-std=c++17 指令编译器基于 C++17 标准的 C++ 语法
-Wall 指令编译时输出所有警告
-Wextra 指令在编译时输出额外的警告
-fno-rtti 这是优化的一部分。 它禁用 RTTI(运行时类型信息)。
RTTI 允许在程序执行期间判定我们不需要的对象类型,因为它会产生性能成本。
-O2 启用优化级别 2,其中包括在级别 1 之上进行更激进的优化
-I. 将包含路径设置为当前目录,因此编译器将能够找到位于同一目录的头文件 example.h
-fPIC 通常,每次构建共享库时需要,因为它指令编译器为将要创建的共享库生成相应的位置无关代码(PIC)
,并与要链接的主程序一起工作。 由于没有固定的内存地址来从共享库加载特定功能,它也提高了安全性。
-lpthread  指令与 pthread 库链接

执行以下命令构建一个与 libexample.so 链接的主测试程序,并以 main.out 输出。

$ g++ -std=c++17 -Wall -Wextra -fno-rtti -O2 -I. -o main.out main.cpp -L. -lexample

与我们上面未提到的不同标志,解释如下

标志 说明
-L. 将共享库的包含路径设置为位于同一目录中
 -lexample 共享库的链接,即 libexample.so

最后,我们执行可执行文件。

$ ./main.out 
--- misc ---
add(1,2): 3
sub(2,1): 1
hardware concurrency: 12
--- end ---

--- single-threaded sum(1000M) ---
elapsed time: 568.401ms
sum: 1000000000
--- end ---

--- multi-threaded sum_v2(1000M) ---
elapsed time: 131.697ms
sum: 1000000000
--- end ---

如图所示的结果表明,与单线程函数相比,多线程函数的工作速度明显更快(~4.33 倍)。

由于我们熟悉如何使用命令行编译和构建共享库和主程序,因此我们进一步扩展它,以便通过 Makefile 制作相应的构建系统。 虽然有 CMake 能做这些,但是由于我们主要在 Linux 上开发和构建,CMake 对我来说似乎用力过猛。 我们不需要这样的兼容性就可以在 Windows 上构建。 故此,Makefile 才是正确的选择。

我们将有 Makefile 的三种变体。

  1. Makefile
    它用于 Linux 和 Windows 的交叉编译。 它实用 pthread。 我们用它来构建与 MetaTrader 5 一起工作的 DLL,此外还有主要测试程序,其启动可通过 Wine。

  2. Makefile-th_win32
    与 Makefile 相同,但它用 win32 线程。

  3. Makefile-g++
    它在本地 Linux 系统上编译。 这是我们上面刚刚完成的步骤。

Makefile

# script to build project with mingw with posix thread
.PHONY: all clean example.dll main.exe

COMPILER := x86_64-w64-mingw32-g++-posix
FLAGS := -O2 -fno-rtti -std=c++17 -Wall -Wextra
MORE_FLAGS ?=

all: example.dll main.exe

example.dll: example.cpp example.h
        $(COMPILER) -shared $(FLAGS) $(MORE_FLAGS) -DEXAMPLE_EXPORT -DWINDOWS -I. -fPIC -o $@ $< -lpthread

main.exe: main.cpp example.dll
        $(COMPILER) $(FLAGS) $(MORE_FLAGS) -I. -DWINDOWS -o $@ $< -L. -lexample

clean:
        rm -f example.dll main.exe

Makefile-th_win32

# script to build project with mingw with win32 thread
.PHONY: all clean example.dll main.exe

COMPILER := x86_64-w64-mingw32-g++-win32
FLAGS := -O2 -fno-rtti -std=c++17 -Wall -Wextra
MORE_FLAGS ?=

all: example.dll main.exe

example.dll: example.cpp example.h
        $(COMPILER) -shared $(FLAGS) $(MORE_FLAGS) -DEXAMPLE_EXPORT -DWINDOWS -DUSE_MINGW_STD_THREAD -I. -fPIC -o $@ $<

main.exe: main.cpp example.dll
        $(COMPILER) $(FLAGS) $(MORE_FLAGS) -I. -DWINDOWS -DUSE_MINGW_STD_THREAD -o $@ $< -L. -lexample

clean:
        rm -f example.dll main.exe

Makefile-g++

# script to build project with mingw with posix thread, for native linux
.PHONY: all clean example.dll main.exe

COMPILER := g++
FLAGS := -O2 -fno-rtti -std=c++17 -Wall -Wextra
MORE_FLAGS ?=

all: libexample.so main.out

libexample.so: example.cpp example.h
        $(COMPILER) -shared $(FLAGS) $(MORE_FLAGS) -I. -fPIC -o $@ $< -lpthread

main.out: main.cpp libexample.so
        $(COMPILER) $(FLAGS) $(MORE_FLAGS) -I. -o $@ $< -L. -lexample

clean:
        rm -f libexample.so main.out

从上面的 3 个 Makefile 变体中,它几乎共享了所有代码,只略有一些最小的差别。

请花一些时间看看它们之间有什么区别,特别是


MORE_FLAGS 被声明为

MORE_FLAGS ?=

这意味着它允许用户从命令行传递其它编译标志,因此用户可以根据需要添加更多所需标志。 如果没有从用户外部传递的标志,则它采用 Makefile 代码中已定义的内容。

接下来,令所有这些 Makefile 可执行,即通过

$ chmod 755 Makefile*

如此,要构建上面的 Makefile 特定变体,请参阅下表。

目标系统 构建命令  清理命令
交叉编译采用 pthread make  make clean
交叉编译采用 win32 线程 make -f Makefile-th_win32  make -f Makefile-th_win32 clean
Native Linux make -f Makefile-g++  make -f Makefile-g++ clean

我们来构建用于 MetaTrader 5 和 Wine 的 DLL。 如此,我们可以同时测试两者。

那么这样做

$ make

我们将生成以下文件

  1. example.dll
  2. main.exe


测试执行交叉编译的可执行文件。

$ wine main.exe
...
0118:err:module:import_dll Library libgcc_s_seh-1.dll (which is needed by L"Z:\\mnt\\datadrive\\_extended\\home\\haxpor\\Data\\Projects\\ExampleLib\\t\\main.exe") not found
0118:err:module:import_dll Library libstdc++-6.dll (which is needed by L"Z:\\mnt\\datadrive\\_extended\\home\\haxpor\\Data\\Projects\\ExampleLib\\t\\main.exe") not found
0118:err:module:import_dll Library libgcc_s_seh-1.dll (which is needed by L"Z:\\mnt\\datadrive\\_extended\\home\\haxpor\\Data\\Projects\\ExampleLib\\t\\example.dll") not found
0118:err:module:import_dll Library libstdc++-6.dll (which is needed by L"Z:\\mnt\\datadrive\\_extended\\home\\haxpor\\Data\\Projects\\ExampleLib\\t\\example.dll") not found
0118:err:module:import_dll Library example.dll (which is needed by L"Z:\\mnt\\datadrive\\_extended\\home\\haxpor\\Data\\Projects\\ExampleLib\\t\\main.exe") not found
0118:err:module:LdrInitializeThunk Importing dlls for L"Z:\\mnt\\datadrive\\_extended\\home\\haxpor\\Data\\Projects\\ExampleLib\\t\\main.exe" failed, status c0000135

好了,我们有一个问题。 main.exe 找不到所需的 DLL。
解决方案是将它们全部放在与我们的可执行文件相同的目录中。

所需的 DLL 如下所示

当我们安装 Mingw 时,这些 DLL 已经存在于我们的 Linux 系统上。 我们只需要找到它。
使用以下技术查找它们。

sudo find / -type f -name libgcc_s_seh-1.dll 2>/dev/null

这个命令将定位 libgcc_s_seh-1.dll,并从根目录开始搜索(使用 /),并忽略目录(使用 -type f)。 如果有任何错误,则将其转储到 /dev/null(通过 2>/dev/null

我们将相关输出如下 

仔细注意 win32 和 posix 作为目录名称的一部分。 如果您通过 Makefile 构建,那么您应该从基于 posix 的目录中复制这些 DLL。 但是,如果您通过 Makefile-th_win32 构建,则应从基于 win32 的目录中复制 DLL。

由于我们选择主要基于 pthread,所以我建议以下内容

最后,我们可以按如下方式测试程序

$ wine main.exe
0098:fixme:hid:handle_IRP_MN_QUERY_ID Unhandled type 00000005

        ... 
0098:fixme:xinput:pdo_pnp IRP_MN_QUERY_ID type 5, not implemented!

        ... 
--- misc ---
add(1,2): 3
sub(2,1): 1
hardware concurrency: 12
--- end ---

--- single-threaded sum(1000M) ---
elapsed time: 416.829ms
sum: 1000000000
--- end ---

--- multi-threaded sum_v2(1000M) ---
elapsed time: 121.164ms
sum: 1000000000
--- end ---

忽略不相关的输出行,这些都是来自 Wine 本身的警告和小错误。

我们看到多线程函数比单线程函数快 ~3.4 倍。 仍然比原生 Linux 构建稍慢,这是可以理解的。 稍后,我们将在完成 MQL5 代码并编译后,再次返回做一个简单的基准测试。

太棒了! 我们接下来准备实现 MQL5 代码。


概念验证,开发阶段 II — 使用 DLL 的 MQL5 代码

如此漫长的旅程,我们终于来到 MQL5 代码开发的第二阶段。

实现 TestConsumeDLL.mq5 脚本。

//+------------------------------------------------------------------+
//|                                               TestConsumeDLL.mq5 |
//|                                          Copyright 2022, haxpor. |
//|                                                 https://wasin.io |
//+------------------------------------------------------------------+
#property copyright "Copyright 2022, haxpor."
#property link      "https://wasin.io"
#property version   "1.00"

#import "example.dll"
const int add(int, int);
const int sub(int, int);
const int num_hardware_concurrency();
const int single_threaded_sum(const int& arr[], int num_elem);
const int multi_threaded_sum_v2(const int& arr[], int num_elem);
#import

void OnStart()
{
   Print("add(1,2): ", example::add(1,2));
   Print("sub(2,1): ", example::sub(2,1));
   Print("Hardware concurrency: ", example::num_hardware_concurrency());

   int arr[];
   ArrayResize(arr, 1000000000);                // 1000M elements
   ArrayFill(arr, 0, ArraySize(arr), 1);

   // benchmark of execution time will be printed on terminal
   int sum = 0;
   Print("--- single_threaded_sum(1000M) ---");
   sum = single_threaded_sum(arr, ArraySize(arr));
   Print("sum: ", sum);
   if (sum != 1000000000) Alert("single_threaded_sum result not correct");
   Print("--- end ---");

   sum = 0;

   Print("--- multi_threaded_sum_v2(1000M) ---");
   sum = multi_threaded_sum_v2(arr, ArraySize(arr));
   Print("sum: ", sum);
   if (sum != 1000000000) Alert("multi_threaded_sum_v2 result not correct");
   Print("--- end ---");
}

当然,您可以创建 MQL5 代码作为智能系统或指标,但对于此概念验证工作,我们需要触及并运行测试的所有步骤和工作流程。 如此,脚本更适合我们的需要。
无论如何,在现实世界中,您通常需要 EA 或指标才能从终端获取数据,即 OnTick(), OnTrade(), OnCalculate()。 有关 MT 平台上每种类型的程序支持哪些功能的更多信息,请参阅程序运行

现在,我们逐模部分剖析上面完整代码模块。

从 DLL 导入函数签名。

#import "example.dll"
const int add(int, int);
const int sub(int, int);
const int num_hardware_concurrency();
const int single_threaded_sum(const int& arr[], int num_elem);
const int multi_threaded_sum_v2(const int& arr[], int num_elem);
#import

为了能够调用从 DLL 公开的函数,我们需要在 MQL5 代码中再次声明这些签名。

注意事项

创建大型数据集数组

   int arr[];
   ArrayResize(arr, 1000000000);                // 1000M elements
   ArrayFill(arr, 0, ArraySize(arr), 1);

这将为 1000M 元素创建整数型数组,并将每个元素的值设置为 1。 而这个数组是动态的,且只能存在于堆上。 堆栈没有足够的空间来容纳如此巨大的数据量。 故此,要令数组成为动态数组,需使用 int arr[] 的声明语法。

之后,我们只需根据需要按声明的签名调用每个 DLL 函数。 另请注意,我们通过检查结果来验证输出,如果不正确,那么要向用户发送 Alert() 。 尽管我们不会立即退出。

调用 ArraySize() 获取数组的元素数量。 若要将数组传递给函数,只需将其变量直接传递给函数即可。

编译脚本,我们实现就完成了。


将所有必需的 DLL 复制到 MetaTrader 5

在我们尝试启动 MQL5 脚本之前。 我们需要将所有必需的 DLL 复制到 <terminal>/Libraries 目录。 通常它的完整路径是 ~/.mt5/drive_c/Program Files/MetaTrader 5/MQL5/Libraries。
这就是 MetaTrader 5 根据我们所构建程序的需要寻找任何必需的 DLL 的地方。 转头返回到测试执行交叉编译的可执行文件部分,查看所要复制的 DLL 列表。

默认情况下,MetaTrader 5 官方安装脚本将自动安装前缀为 ~/.mt5 的 Wine。 这仅适用于运行官方安装脚本的用户。


测试

将编译的 TestConsumeDLL 拖放到图表上。

将编译的 TestConsumeDLL 拖放到图表上,并开始执行。

首先在 Linux 上测试通过 Wine 启动的 MetaTrader 5。
将编译的 TestConsumeDLL 拖放到图表上。 然后您将看到它显示一个对话框,询问允许从 DLL 导入的权限,以及我们构建的此类 MQL5 程序的 DLL 依赖项列表。

请求 DLL 导入权限的对话框,以及 DLL 依赖项的列表。

请求 DLL 导入权限的对话框,以及 DLL 依赖项的列表。

尽管我们没有看到 libwinpthread-1.dll,因为它不是编译的 MQL5 脚本的直接依赖项,但它是 libgcc_s_seh-1.dlllibstdc++6.dll 的依赖项。我们可以使用 objdump 检查目标 DLL 文件的依赖关系,如下所示。

$ objdump -x libstdc++-6.dll  | grep DLL
        DLL
 vma:            Hint    Time      Forward  DLL       First
        DLL Name: libgcc_s_seh-1.dll
        DLL Name: KERNEL32.dll
        DLL Name: msvcrt.dll
        DLL Name: libwinpthread-1.dll

$ objdump -x libgcc_s_seh-1.dll  | grep DLL
        DLL
 vma:            Hint    Time      Forward  DLL       First
        DLL Name: KERNEL32.dll
        DLL Name: msvcrt.dll
        DLL Name: libwinpthread-1.dll


objdump 能够读取 Windows 和 Linux 创建的二进制文件(共享库或可执行文件)。 根据需要转储可用信息都是通用的。 -x 标志表示显示所有头文件的内容。

标志表示显示所有头文件的内容。 在 “Experts” 选项卡中查看结果输出

在 “Experts” 选项卡中看到的,来自所执行 TestConsumeDLL 的输出。

来自所执行 TestConsumeDLL 的输出,如在 “Experts” 选项卡中所见。


以及在 Linux 上启动 MetaTrader 5,其原始终端窗口执行每个函数所经历时间的结果。

在控制台上输出每个函数执行所用的时间

在用于启动 MetaTrader 5 的同一终端窗口中,用户将看到 DLL 执行输出的经历时间。

只要您没有看到来自 Alerts() 的任何警报,那就可以认为执行所经历的时间就是正确的。 然后一切都很好,我们几乎完成了这个概念验证程序中的所有事情。


在 Windows 系统上测试

我们还需要以下内容

Virtualbox 主界面

Virtualbox 主界面。 取决于您是否有硬件资源来容留它,如果您需要在 DLL 上测试执行速度,越多越好。


还取决于您的计算机资源的丰腴程度和可用性,这些资源可以容留给通过 Virtualbox 启动的 Windows 系统,以便测试 DLL 执行的速度。 就我的情况,我有以下配置

现在到测试时候了。 我们可以从 Linux 机器复制编译好的 MQL5 代码,也可以只复制所有代码,然后在 Windows 机器上利用 MetaEditor 再次编译它。 我发现后一个选项完全没问题,它只是另一个复制和粘贴。 如此,我就这样做了。

测试结果

在 Windows 的 “Experts“ 选卡上 TestConsumeDLL 的输出结果

在 Windows 的 “Experts“ 选卡上输出结果如测试所示


问题是执行中经历的时间被编码为通过标准输出(stdout)输出,我找不到一种方法来捕获在 Windows 上启动 MetaTrader 5 的输出。 我尝试的一种方法是使用配置文件启动 MetaTrader 5,以便从头开始执行脚本,然后将输出重定向到文件;但尝试失败,因为 MetaTrader 5 不允许在启动时从命令行加载任何 DLL。 如此,为了在不干扰主 DLL 代码的情况下对此进行修改,我们将对 MQL5 代码进行细微调整,调用 GetTickCount() 来计算从开始的执行时间。

   ...
   int sum = 0;
   uint start_time = 0;
   uint elapsed_time = 0; 

   Print("--- single_threaded_sum(1000M) ---");
   start_time = GetTickCount();                         // *
   sum = single_threaded_sum(arr, ArraySize(arr));
   elapsed_time = GetTickCount() - start_time;          // *
   Print("sum: ", sum);
   if (sum != 1000000000) Alert("single_threaded_sum result not correct");
   Print("elapsed time: ", elapsed_time, " ms");
   Print("--- end ---");

   sum = 0;
   start_time = 0;
   elapsed_time = 0;

   Print("--- multi_threaded_sum_v2(1000M) ---");
   start_time = GetTickCount();                         // *
   sum = multi_threaded_sum_v2(arr, ArraySize(arr));
   elapsed_time = GetTickCount() - start_time;          // *
   Print("sum: ", sum);
   if (sum != 1000000000) Alert("multi_threaded_sum_v2 result not correct");
   Print("elapsed time: ", elapsed_time, " ms");
   Print("--- end ---");
}

请注意带有注释的行,就是说有 “// *”。 这是需要注意的主要附加行。 这很容易理解。

现在,我们重新测试。

TestConsumeDLL Windows 重测结果如在 ”Experts“ 选卡所示

更新的 MQL5 代码以衡量在 Windows 上测试的所用执行时间。


我们已经完成了整个概念验证应用程序,生成了具有多线程功能的 DLL,然后在 MQL5 代码中调用它,在 Linux 和 Windows 系统上进行测试,而所有这些都是在 Linux 上启动和开发。 所有工作均按预期工作,且符合预期结果。


两种 Mingw 线程实现的简单基准测试

我们将以简单的方式进行基准测试,特别是基于我们的概念验证计划。 因为若要针对跨平台 C++ 多线程功能进行全面基准测试,需要考虑多种因素,尤其是多个同步原语、thread_local、问题域、等等。

我们如何进行基准测试如下

结果数字需四舍五入到小数点后 2 位。

结果如下表所示。

函数  Linux + pthread (ms) Linux + win32 线程 (ms) Windows + pthread (ms) Windows + win32 线程 (ms)
 single_threaded_sum 417.53
417.20
467.77
475.00
 multi_threaded_sum_v2  120.91  122.51  121.98  125.00


结束语

Mingw 和 Wine 是跨平台工具,允许开发人员在 Linux 上开发跨平台应用程序,该应用程序能于 Linux 和 Windows 上无缝运行。 它也适用于为 MT 平台开发的情况。 通过我们的概念验证应用程序,利用 C++ 多线程能力来开发 DLL,在 Linux 和 Windows 上都能够通过测试,它提供了诸多替代选项,从而扩大了开发人员至生态系统的范围。