53、多线程编程中的同步、存储与异步模式解析

多线程编程中的同步、存储与异步模式解析

在多线程编程领域,存在着诸多复杂的问题和有效的解决方案。下面将详细介绍线程本地存储、定时器以及异步编程模型等关键内容。

线程本地存储

在某些情况下,使用同步锁会导致性能下降和可扩展性受限,或者对特定数据元素进行同步操作可能过于复杂。线程本地存储(Thread Local Storage)是实现数据隔离的一种有效方法,它让每个线程都拥有自己独立的变量实例,从而避免了同步的需求。

ThreadLocal

在 .NET Framework 4 中,可以通过声明 ThreadLocal<T> 类型的字段来使用线程本地存储。以下是示例代码:

using System;
using System.Threading;
class Program 
{
    public static double Count
    {
        get { return _Count.Value; }
        set { _Count.Value = value; }
    }
    public static void Main()
    {
        Thread thread = new Thread(Decrement);
        static ThreadLocal<double> _Count = 
            new ThreadLocal<double>(() => 0.01134);
        thread.Start();
        // Increment
        for (double i = 0; i < short.MaxValue; i++)
        {
            Count++;
        }
        thread.Join();
        Console.WriteLine("Main Count = {0}", Count);
    }
    static void Decrement()
    {
        Count = -Count;
        for (double i = 0; i < short.MaxValue; i++)
        {
            Count--;
        }
        Console.WriteLine(
            "Decrement Count = {0}", Count);
    } 
}

在上述代码中,执行 Main() 方法的线程和执行 Decrement() 方法的线程对 Count 的操作是相互独立的。 Main() 线程的初始值为 0.01134,最终值为 32767.01134; Decrement() 线程的初始值为 -0.01134,最终值为 -32767.01134。这表明每个线程都有自己独立的 _Count.Value 实例。

ThreadStaticAttribute

另一种实现线程本地存储的方法是使用 ThreadStaticAttribute 修饰静态字段。示例代码如下:

using System;
using System.Threading;
class Program 
{
    [ThreadStatic]
    static double _Count = 0.01134;
    public static double Count
    {
        get { return Program._Count; }
        set { Program._Count = value; }
    }
    public static void Main()
    {
        Thread thread = new Thread(Decrement);
        thread.Start();
        // Increment
        for (int i = 0; i < short.MaxValue; i++)
        {
            Count++;
        }
        thread.Join();
        Console.WriteLine("Main Count = {0}", Count);
    }
    static void Decrement()
    {
        for (int i = 0; i < short.MaxValue; i++)
        {
            Count--;
        }
        Console.WriteLine("Decrement Count = {0}", Count);
    } 
}

在这个例子中,执行 Main() 方法的线程和执行 Decrement() 方法的线程对 Count 的操作同样是独立的。不过,需要注意的是,只有执行静态构造函数的线程关联的线程本地存储实例会被初始化为 0.01134, Decrement() 线程中的 _Count 会被初始化为 0。因此,在每个线程最初调用的方法中初始化线程本地存储字段是一个良好的实践。

是否使用线程本地存储需要进行成本效益分析。例如,对于数据库连接,创建每个线程的连接可能成本较高,而对连接进行加锁同步又会限制可扩展性。此外,线程本地存储还可以用于在不通过参数显式传递数据的情况下,让常用的上下文信息对其他方法可用,从而保持 API 的简洁性。

定时器

在使用定时器类时,可能会意外出现与用户界面相关的线程问题。因为定时器通知回调触发时,执行的线程可能不是用户界面线程,从而无法安全地访问用户界面控件和表单。

常见的定时器类有 System.Windows.Forms.Timer System.Timers.Timer System.Threading.Timer System.Windows.Forms.Timer 是专门为富客户端用户界面设计的,它可以安全地从能够与用户界面交互的线程触发事件。 System.Timers.Timer System.Threading.Timer 的包装器,抽象并增加了一些功能。 System.Threading.Timer 不继承自 System.ComponentModel.Component ,不能作为组件放在组件容器中,但它允许在启动定时器时传递状态参数。

以下是不同定时器的特性对比表格:
| 特性 | System.Timers.Timer | System.Threading.Timer | System.Windows.Forms.Timer |
| — | — | — | — |
| 支持实例化后添加和移除监听器 | Yes | No | Yes |
| 支持在用户界面线程上回调 | Yes | No | Yes |
| 从线程池获取的线程回调 | Yes | Yes | No |
| 支持在 Windows 窗体设计器中拖放 | Yes | No | Yes |
| 适用于多线程服务器环境 | Yes | Yes | No |
| 支持从定时器初始化传递任意状态到回调 | No | Yes | No |
| 实现 IDisposable | Yes | Yes | Yes |
| 支持开关回调和周期性重复回调 | Yes | Yes | Yes |
| 可跨应用程序域边界访问 | Yes | Yes | Yes |
| 支持 IComponent,可托管在 IContainer 中 | Yes | No | Yes |

对于用户界面编程, System.Windows.Forms.Timer 是一个明显的选择,但需要注意长时间运行的操作可能会延迟定时器到期。在 System.Timers.Timer System.Threading.Timer 之间选择时,如果需要托管在 IContainer 中,则选择 System.Timers.Timer ;如果不需要特定功能,默认选择 System.Threading.Timer ,因为它的实现更轻量级。

以下是使用 System.Timers.Timer 的示例代码:

using System;
using System.Timers; 
using System.Threading; 
// Because Timer exists in both the System.Timers and 
// System.Threading namespaces, you disambiguate "Timer" 
// using an alias directive.
class UsingSystemTimersTimer 
{
    private static int _Count=0;
    private static readonly ManualResetEvent _ResetEvent =
       new ManualResetEvent(false);
    private static int _AlarmThreadId;
    public static void Main()
    {
        using Timer = System.Timers.Timer;
        using( Timer timer = new Timer() )
        {
            // Initialize Timer
            timer.AutoReset = true;
            timer.Interval = 1000;
            timer.Elapsed += 
                new ElapsedEventHandler(Alarm);
            timer.Start();
            // Wait for Alarm to fire for the 10th time.
            _ResetEvent.WaitOne();
        }
        // Verify that the thread executing the alarm
        // Is different from the thread executing Main
        if(_AlarmThreadId == 
            Thread.CurrentThread.ManagedThreadId)
        {
            throw new ApplicationException(
                "Thread Ids are the same.");
        }
        Console.WriteLine(
            "(Alarm Thread Id) {0} != {1} (Main Thread Id)",
            _AlarmThreadId,
            Thread.CurrentThread.ManagedThreadId);
        Console.WriteLine(
            "Final Count = {0}", _Count);
    }
    static void Alarm(
        object sender, ElapsedEventArgs eventArgs)
    {
        _Count++;
        Console.WriteLine("{0}:- {1}",
            _Count,
            eventArgs.SignalTime.ToString("T"));
        if (_Count >= 9)
        {
            _AlarmThreadId = 
                Thread.CurrentThread.ManagedThreadId;
            _ResetEvent.Set();
        }
    } 
}

以下是使用 System.Threading.Timer 的示例代码:

using System;
using System.Threading;
class UsingSystemThreadingTimer 
{
    private static int _Count=0;
    private static readonly AutoResetEvent _ResetEvent =
        new AutoResetEvent(false);
    private static int _AlarmThreadId;
    public static void Main()
    {
        using( Timer timer = 
            new Timer(Alarm, null, 0, 1000) )
        {
            // Wait for Alarm to fire for the 10th time.
            _ResetEvent.WaitOne();
        }
        // Verify that the thread executing the alarm
        // Is different from the thread executing Main
        if(_AlarmThreadId == 
            Thread.CurrentThread.ManagedThreadId)
        {
            throw new ApplicationException(
                "Thread Ids are the same.");
        }
        if(_Count < 9)
        {
            throw new ApplicationException(
                " _Count < 9");
        }
        Console.WriteLine(
            "(Alarm Thread Id) {0} != {1} (Main Thread Id)",
            _AlarmThreadId,
            Thread.CurrentThread.ManagedThreadId);
        Console.WriteLine(
            "Final Count = {0}", _Count);
    }
    static void Alarm(object state)
    {
        _Count++;
        Console.WriteLine("{0}:- {1}",
            _Count,
            DateTime.Now.ToString("T"));
        if (_Count >= 9)
        {
            _AlarmThreadId =
                Thread.CurrentThread.ManagedThreadId;
            _ResetEvent.Set();
        }
    } 
}
异步编程模型

多线程编程存在诸多复杂性:
1. 监控异步操作状态以确定其是否完成,最好不通过轮询线程状态或阻塞等待。
2. 使用线程池避免启动和销毁线程的高成本,防止创建过多线程导致系统频繁切换线程。
3. 避免死锁,保护数据不被多个线程同时访问。
4. 确保操作的原子性并同步数据访问,通过对操作组添加同步机制,使操作作为一个整体执行,并防止被其他线程不当中断。

当方法执行时间较长时,通常需要进行多线程编程,以异步方式调用该方法。随着开发者编写更多的多线程代码,出现了一些常见的场景和编程模式,其中异步编程模型(APM)尤为突出。对于一个长时间运行的同步方法 X() ,APM 使用 BeginX() 方法异步启动 X() 的等效工作,使用 EndX() 方法结束该工作。

调用 APM

以下是使用 System.Net.WebRequest 类下载网页的示例代码,展示了如何调用 APM:

using System;
using System.IO; 
using System.Net; 
using System.Linq;
public class Program 
{
    public static void Main(string[] args)
    {
        string url = "http://www.intelliTechture.com";
        if (args.Length > 0)
        {
            url = args[0];
        }
        Console.Write(url);
        WebRequest webRequest = WebRequest.Create(url);
        IAsyncResult asyncResult = 
            webRequest.BeginGetResponse(null, null);
        // Indicate busy using dots
        while (
            !asyncResult.AsyncWaitHandle.WaitOne(100))
        {
            Console.Write('.');
        }
        WebResponse response = 
            webRequest.EndGetResponse(asyncResult);
        // Retrieve the results when finished 
        // downloading
        using (StreamReader reader = 
            new StreamReader(response.GetResponseStream()))
        {
            int length = reader.ReadToEnd().Length;
            Console.WriteLine(FormatBytes(length));
        }
    }
    static public string FormatBytes(long bytes)
    {
        string[] magnitudes = 
            new string[] { "GB", "MB", "KB", "Bytes" };
        long max = 
            (long)Math.Pow(1024, magnitudes.Length);
        return string.Format("{1:##.##} {0}",
            magnitudes.FirstOrDefault(
                magnitude => 
                    bytes > (max /= 1024) )?? "0 Bytes",
                (decimal)bytes / (decimal)max).Trim();
    } 
}

APM 的关键在于 BeginX EndX 方法对,它们具有明确的签名。 BeginX 返回一个 System.IAsyncResult 对象,用于访问异步调用的状态, EndX 方法将该对象作为输入参数。每个 BeginX 调用必须对应一个 EndX 调用。 EndX 方法有四个主要作用:
1. 阻塞后续执行,直到请求的工作成功完成(或出错抛出异常)。
2. 如果方法 X 返回数据,可从 EndX 方法调用中获取该数据。
3. 若执行请求工作时发生异常,在 EndX 调用时会重新抛出异常。
4. 负责清理因调用 X 而需要清理的资源。

APM 签名

BeginX EndX 方法的组合签名应与同步方法的签名匹配。例如,对于虚构的同步方法 bool TryDoSomething(string url, ref string data, out string[] links) ,其 APM 方法的参数映射如下:

System.IAsyncResult BeginTryDoSomething(
String url, ref string data, out string[] links,
System.AsyncCallback callback, object state)
bool EndTryDoSomething (ref string data, out string[] links,
System.IAyncResult result);
bool TryDosomething( 
string url, ref string data, out string[] links) 

所有输入参数映射到 BeginX 方法,返回参数映射到 EndX 方法的返回参数。 ref out 参数由于会返回结果,也包含在 EndX 方法签名中。

带 AsyncCallback 的延续传递风格(CPS)

BeginX 方法还有两个在同步方法中未包含的参数:回调参数 System.AsyncCallback 和状态参数 object 。以下是示例代码:

using System;
using System.IO; 
using System.Net; 
using System.Linq; 
using System.Threading;
public class Program 
{
    public static void Main(string[] args)
    {
        string url = "http://www.intelliTechture.com";
        if (args.Length > 0)
        {
            url = args[0];
        }
        Console.Write(url);
        WebRequest webRequest = WebRequest.Create(url);
        WebRequestState state = new WebRequestState(webRequest);
        IAsyncResult asyncResult = 
            webRequest.BeginGetResponse(
                GetResponseAsyncCompleted, state);
        // Indicate busy using dots
        while (
            !asyncResult.AsyncWaitHandle.WaitOne(100))
        {
            Console.Write('.');
        }
        state.ResetEvent.Wait();
    }
    private static void GetResponseAsyncCompleted(
        IAsyncResult asyncResult)
    {
        WebRequestState completedState = 
            (WebRequestState)asyncResult.AsyncState;
        HttpWebResponse response =
            (HttpWebResponse)completedState.WebRequest
                .EndGetResponse(asyncResult);
        // Retrieve the results when finished downloading
        Stream stream = response.GetResponseStream();
        StreamReader reader = new StreamReader(stream);
        int length = reader.ReadToEnd().Length;
        Console.WriteLine(FormatBytes(length));
        completedState.ResetEvent.Set();
        completedState.Dispose();
    }
    static public string FormatBytes(long bytes)
    {
        string[] magnitudes = 
            new string[] { "GB", "MB", "KB", "Bytes" };
        long max = 
            (long)Math.Pow(1024, magnitudes.Length);
        return string.Format("{1:##.##} {0}",
            magnitudes.FirstOrDefault(
                magnitude => 
                    bytes > (max /= 1024) )?? "0 Bytes",
                (decimal)bytes / (decimal)max).Trim();
    } 
}
class WebRequestState : IDisposable 
{
    public WebRequestState(WebRequest webRequest)
    {
        WebRequest = webRequest;
    }
    public WebRequest WebRequest { get; private set; }
    private ManualResetEventSlim _ResetEvent =
        new ManualResetEventSlim();
    public ManualResetEventSlim ResetEvent
        { get { return _ResetEvent; } }
    public void Dispose()
    {
        ResetEvent.Dispose();
        GC.SuppressFinalize(this);
    } 
}

注册回调可以实现延续传递风格(CPS),避免将 EndGetResponse() Console.WriteLine() 代码顺序放在 BeginGetResponse() 之后。虽然仍需调用 EndGetResponse() ,但将其放在回调中可确保在异步调用完成时不会阻塞主线程。

在 APM 方法之间传递状态

除了 AsyncCallback 参数,状态参数用于在回调执行时传递额外数据。可以使用自定义类(如 WebRequestState )或匿名方法(包括 Lambda 表达式)来传递状态。以下是使用匿名方法传递状态的示例代码:

using System;
using System.IO; 
using System.Net; 
using System.Linq; 
using System.Threading;
public class Program 
{
    public static void Main(string[] args)
    {
        string url = "http://www.intelliTechture.com";
        if (args.Length > 0)
        {
            url = args[0];
        }
        Console.Write(url);
        WebRequest webRequest = WebRequest.Create(url);
        ManualResetEventSlim resetEvent =
            new ManualResetEventSlim();
        IAsyncResult asyncResult = 
            webRequest.BeginGetResponse(
                (completedAsyncResult) =>
                {
                    HttpWebResponse response =
                        (HttpWebResponse)webRequest.EndGetResponse(
                        completedAsyncResult);
                    Stream stream = 
                        response.GetResponseStream();
                    StreamReader reader = 
                        new StreamReader(stream);
                    int length = reader.ReadToEnd().Length;
                    Console.WriteLine(FormatBytes(length));
                    resetEvent.Set();
                    resetEvent.Dispose();
                }, null);
        // Indicate busy using dots
        while (
            !asyncResult.AsyncWaitHandle.WaitOne(100))
        {
            Console.Write('.');
        }
        resetEvent.Wait();
    }
    static public string FormatBytes(long bytes)
    {
        string[] magnitudes = 
            new string[] { "GB", "MB", "KB", "Bytes" };
        long max = 
            (long)Math.Pow(1024, magnitudes.Length);
        return string.Format("{1:##.##} {0}",
            magnitudes.FirstOrDefault(
                magnitude => 
                    bytes > (max /= 1024) )?? "0 Bytes",
                (decimal)bytes / (decimal)max).Trim();
    } 
}

需要注意的是,使用 ManualResetEvent 来信号 AsyncCallback 完成,因为 IAsyncResult WaitHandle 会在异步方法完成但 AsyncCallback 执行之前设置。如果仅阻塞 IAsyncResult WaitHandle ,可能会在 AsyncCallback 执行之前退出程序。

资源清理

APM 的另一个重要规则是,即使错误地未调用 EndX 方法,也不应发生资源泄漏。例如, WebRequestState 类拥有 ManualResetEvent ,需要确保该资源得到正确清理。

综上所述,线程本地存储、定时器和异步编程模型是多线程编程中非常重要的概念和技术,合理运用它们可以提高程序的性能和可维护性。在实际开发中,需要根据具体需求和场景选择合适的方法和工具。

多线程编程中的同步、存储与异步模式解析

异步编程模型的进一步分析
APM 模式的流程图

下面是一个简单的 mermaid 流程图,展示了 APM 模式的基本流程:

graph LR
    A[调用 BeginX 方法] --> B[异步执行 X 方法]
    B --> C{X 方法完成?}
    C -- 否 --> B
    C -- 是 --> D[调用 EndX 方法]
    D --> E[处理结果或异常]

这个流程图清晰地展示了 APM 模式从开始异步操作到最终处理结果的整个过程。当调用 BeginX 方法后,系统会异步执行 X 方法,不断检查是否完成,完成后调用 EndX 方法进行结果处理或异常捕获。

APM 模式的实际应用场景

APM 模式在许多需要处理长时间运行操作的场景中非常有用。例如,在网络编程中,像文件下载、网页请求等操作通常会花费较长时间,使用 APM 模式可以避免阻塞主线程,提高程序的响应性能。

在数据库操作方面,当执行复杂的查询或批量数据处理时,使用 APM 模式可以让程序在等待数据库响应的同时继续执行其他任务,从而提高整体效率。

线程本地存储的应用案例分析
数据库连接池中的应用

在多线程环境下使用数据库连接池时,线程本地存储可以发挥重要作用。每个线程可以拥有自己独立的数据库连接,避免多个线程同时竞争同一个连接,从而减少锁的使用,提高性能。

以下是一个简单的示例代码,展示了如何在线程本地存储中管理数据库连接:

using System;
using System.Data.SqlClient;
using System.Threading;

class DatabaseConnectionManager
{
    private static ThreadLocal<SqlConnection> _connection = new ThreadLocal<SqlConnection>(() =>
    {
        string connectionString = "YourConnectionString";
        return new SqlConnection(connectionString);
    });

    public static SqlConnection GetConnection()
    {
        return _connection.Value;
    }
}

在上述代码中,每个线程都有自己独立的 SqlConnection 实例,通过 GetConnection 方法可以获取当前线程的数据库连接。

日志记录中的应用

在多线程应用程序中进行日志记录时,线程本地存储可以用来存储每个线程的日志上下文信息。例如,在一个 Web 应用程序中,每个请求可能由不同的线程处理,使用线程本地存储可以记录每个请求的相关信息,如请求的 URL、用户 ID 等。

using System;
using System.Threading;

class LogContext
{
    private static ThreadLocal<string> _requestUrl = new ThreadLocal<string>();
    private static ThreadLocal<int> _userId = new ThreadLocal<int>();

    public static void SetRequestUrl(string url)
    {
        _requestUrl.Value = url;
    }

    public static void SetUserId(int id)
    {
        _userId.Value = id;
    }

    public static void LogMessage(string message)
    {
        Console.WriteLine($"[{_requestUrl.Value} - User: {_userId.Value}] {message}");
    }
}

在上述代码中, SetRequestUrl SetUserId 方法用于设置当前线程的请求 URL 和用户 ID, LogMessage 方法可以使用这些信息进行日志记录。

定时器的使用建议和注意事项
定时器的选择流程

以下是一个 mermaid 流程图,展示了如何根据不同的需求选择合适的定时器:

graph LR
    A[是否用于用户界面编程?] -->|是| B[使用 System.Windows.Forms.Timer]
    A -->|否| C[是否需要托管在 IContainer 中?]
    C -->|是| D[使用 System.Timers.Timer]
    C -->|否| E[使用 System.Threading.Timer]

这个流程图可以帮助开发者根据具体需求快速选择合适的定时器。

定时器的性能优化

在使用定时器时,需要注意性能优化。例如,避免在定时器的回调方法中执行长时间运行的操作,因为这可能会导致定时器的触发时间不准确,甚至影响整个应用程序的性能。

另外,如果定时器的间隔时间设置得非常短,可能会导致系统资源的过度消耗。因此,需要根据实际需求合理设置定时器的间隔时间。

多线程编程的最佳实践总结
同步与异步的选择

在多线程编程中,需要根据具体的场景选择合适的同步或异步方式。如果操作是短时间的,并且需要立即得到结果,同步方式可能更合适;如果操作是长时间运行的,如网络请求、文件读写等,异步方式可以避免阻塞主线程,提高程序的响应性能。

资源管理

在多线程编程中,资源管理非常重要。确保在使用完资源后及时释放,避免资源泄漏。例如,在使用数据库连接、文件句柄等资源时,要使用 using 语句或手动调用 Dispose 方法进行资源清理。

错误处理

多线程编程中,错误处理也需要特别注意。在异步操作中,异常可能不会立即抛出,而是在 EndX 方法调用时抛出。因此,需要在合适的位置捕获并处理异常,确保程序的稳定性。

总之,多线程编程涉及到同步、存储、定时器和异步编程模型等多个方面的知识。通过合理运用这些技术和遵循最佳实践,可以开发出高性能、可维护的多线程应用程序。在实际开发中,需要根据具体需求和场景进行选择和调整,以达到最佳的效果。

内容概要:本文为《科技类企业品牌传播白皮书》,系统阐述了新闻媒体发稿、自媒体博主种草短视频矩阵覆盖三大核心传播策略,并结合“传声港”平台的AI工具资源整合能力,提出适配科技企业的品牌传播解决方案。文章深入分析科技企业传播的特殊性,包括受众圈层化、技术复杂性传播通俗性的矛盾、产品生命周期影响及2024-2025年传播新趋势,强调从“技术输出”向“价值引领”的战略升级。针对三种传播方式,分别从适用场景、操作流程、效果评估、成本效益、风险防控等方面提供详尽指南,并通过平台AI能力实现资源智能匹配、内容精准投放全链路效果追踪,最终构建“信任—种草—曝光”三位一体的传播闭环。; 适合人群:科技类企业品牌市场负责人、公关传播从业者、数字营销管理者及初创科技公司创始人;具备一定品牌传播基础,关注效果可量化AI工具赋能的专业人士。; 使用场景及目标:①制定科技产品全生命周期的品牌传播策略;②优化媒体发稿、KOL合作短视频运营的资源配置ROI;③借助AI平台实现传播内容的精准触达、效果监测风险控制;④提升品牌在技术可信度、用户信任市场影响力方面的综合竞争力。; 阅读建议:建议结合传声港平台的实际工具模块(如AI选媒、达人匹配、数据驾驶舱)进行对照阅读,重点关注各阶段的标准化流程数据指标基准,将理论策略平台实操深度融合,推动品牌传播从经验驱动转向数据工具双驱动。
【3D应力敏感度分析拓扑优化】【基于p-范数全局应力衡量的3D敏感度分析】基于伴随方法的有限元分析和p-范数应力敏感度分析(Matlab代码实现)内容概要:本文档围绕“基于p-范数全局应力衡量的3D应力敏感度分析”展开,介绍了一种结合伴随方法有限元分析的拓扑优化技术,重点实现了3D结构在应力约束下的敏感度分析。文中详细阐述了p-范数应力聚合方法的理论基础及其在避免局部应力过高的优势,并通过Matlab代码实现完整的数值仿真流程,涵盖有限元建模、灵敏度计算、优化迭代等关键环节,适用于复杂三维结构的轻量化高强度设计。; 适合人群:具备有限元分析基础、拓扑优化背景及Matlab编程能力的研究生、科研人员或从事结构设计的工程技术人员,尤其适合致力于力学仿真优化算法开发的专业人士; 使用场景及目标:①应用于航空航天、机械制造、土木工程等领域中对结构强度和重量有高要求的设计优化;②帮助读者深入理解伴随法在应力约束优化中的应用,掌握p-范数法处理全局应力约束的技术细节;③为科研复现、论文写作及工程项目提供可运行的Matlab代码参考算法验证平台; 阅读建议:建议读者结合文中提到的优化算法原理Matlab代码同步调试,重点关注敏感度推导有限元实现的衔接部分,同时推荐使用提供的网盘资源获取完整代码测试案例,以提升学习效率实践效果。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值