(十四)多线程与异步

1.多线程可以干什么

当我们在更新一个客户个人信息的时候,会向访问数据库,并向数据库提交数据。但通常来说,数据库不一定会保存在我们自己的电脑上,所以,客户信息的更新操作很有可能会涉及数据库的远程访问。一旦涉及远程操作,那么这个操作的时效性就完全取决与你当前的网络环境了。如果数据库访问不顺畅,那么就有可能造成操作出现延迟,甚至是阻塞UI的情况。

public void SaveCustomer(string name, string idNumber, string address)
{
    if(SelectedCustomer != null)
    {
        // 更新客户数据
        using (var db = new AppDbContext())
        {
            var customer = db.Customers.Where(c => c.Id == SelectedCustomer.Id).FirstOrDefault();
            customer.Name = name;
            customer.IdNnumber = idNumber;
            customer.Address = address;
            db.SaveChanges();
        }
    }
    else
    {
        // 添加新客户
        using (var db = new AppDbContext())
        {
            var newCustomer = new Customer()
            {
                Name = name,
                IdNnumber = idNumber,
                Address = address
            };
            db.Customers.Add(newCustomer);
            db.SaveChanges();
        }
        LoadCustomers();
    }
	//模拟网络延迟
    Thread.Sleep(10000);
}

WPF项目跑起来,随便选一个客户,给他改个名字,点击更新。好了,现在,我们的项目卡住了,整个页面就像静止了一样,不管点击哪里都没有反应了,不过,页面静止10秒以后,就又能恢复正常了。刚刚我们遇到的就是所谓的UI阻塞。

UI阻塞、整个页面卡住的情况是我们无论如何都需要避免的。
对此,我们有两种处理方法,第一种是在页面进行网络或者耗时数据处理的时候加上loading转菊花,通过界面的反馈,让用户知道程序还在执行任务。但是,这种方式的用户体验其实并不是那么友好,在转菊花的过程中,用户啥都干不了,只能干等着。
所以,另一种更好的处理方式是通过多线程来避免UI阻塞。

1.1 多线程运行

我们可以在程序中使用两个线程,一个线程处理UI渲染,另一个线程处理数据库访问。这两个线程可以同时运行,UI渲染与数据库操作同时进行,互不干扰。

使用class Task,调用run方法,这样我们就创建了一个与主线程(或者叫UI线程)并行的子线程了。而构造方法的参数接收的是一个lambda表达式,所有跑在表达式内部的代码都属于新创建的子线程。那么我们把 _viewModel.SaveCustome挪到子线程内部。

private void SaveCustomer_Click(object sender, RoutedEventArgs e)
{
    try
    {
        string name = NameTextBox.Text.Trim();
        string idNumber = IdTextBox.Text.Trim();
        string address = AddressTextBox.Text.Trim();
        Task.Run(() =>
        {
            _viewModel.SaveCustomer(name, idNumber, address);
        });
    }
    catch (Exception error)
    {
        MessageBox.Show(error.ToString());
    }
}

或者

Thread thread= new Thread(()=>{
	_viewModel.SaveCustomer(name, idNumber, address);
});
thread.Start();

2. 进程与线程

2.1 进程

  • 把程序读取到内存中,让cpu来使用代码指令完成操作任务。而这个加载到内存中的可执行程序的实例就叫做进程,硬盘中不同的程序可以在内存中创建不同的进程。
  • 而即便是同一个程序也可以通过在内存中创建多个实例的方式来运行在多个进程上。
  • 微信双开同学们都用过吧。像微信双开一样,保存在硬盘中的代码可以被多次、重复性的读取到内存中,每次都会开辟一段新的内存空间、创建一个新的可执行程序实例。每个实例之前完全独立、互不影响。

2.2 线程

  • 简单来说,一个线程就是一个小任务、一小段代码片段,是程序执行的最小单元
  • 所以,一次页面的渲染、一次点击事件的触发,一次数据库的访问、一次登录操作这些都可以看作是一个一个的线程。
  • 比如,当我们在点击修改用户名的时候。WPF系统,也就是这个WPF进程会创建两个线程,一个用来渲染UI、另一个用来处理数据库访问和更新。
  • 这两个进程同时运行。一个进程中启动多个线程并行处理任务就叫做多线程操作。

2.3 线程的执行

一般来说,我们程序员的代码处理核心会集中在多线程的逻辑处理上,可能会涉及到线程之间的访问、资源共享和锁定等各种各样的问题;而多个线程的运行一般由CPU来自动处理。

假如现在我的计算机只有一个单核CPU,也就是一次只能执行最多一个线程。那就需要对每个线程轮流执行。

  • 每次单个线程的执行时间称为CPU时间片段,大约是几十到几百毫秒不等,由于CPU的执行速度太快了,用户根本就感觉不到。正是因为CPU的执行效率非常高,所以,让用户有了一种多个程序在并列运行的错觉。
  • 而每个线程都会有“就绪”、“运行”、和“阻塞”三种状态,每当线程“就绪”的时候,CPU就会跑过来执行它,那么它的状态就会变成“运行”。
  • 假如线程在运行过程中,向数据库发起访问请求,然后等待数据库的响应。那么这个时候CPU也会随着线程一起等待数据库响应,那么CPU就变成空转了。所以,线程这个时候就会变为阻塞状态。
  • 那么这个时候,CPU就会转而执行其他的线程了。直到数据库响应了,线程的状态就会从“阻塞”状态变为“就绪”,等待的CPU再次光临,继续执行接下来的任务。

2.4 多线程并行执行机制

如果你的cpu是多核处理器,比如说我的CPU有4个核,那么我的cpu就可以做到同时处理4个线程,这样就可以从真正意义上做到多线程并行。线程的最大并行数量上限是CPU核心的数量,但是,往往你的电脑运行的线程的数量远大于cpu核心的数量,所以 还是需要CPU时间片的轮换。

2.5 代码演示

  1. 首先,我们在main方法中创建一个for循环,循环1000次,每次循环输出一个零。
  2. 然后创建一个独立的静态方法,Print1,在这个方法中使用for循环输出1000个1。并且在main方法中调用一下这个Print1方法。
  3. 如果,我们现在运行程序,会看到什么呢?我们看到命令行连续输出了1000个0,然后输出了1000个1对吧。那么接下来,我们使用多线程的思想来输出这坨0和1。
static void Main(string[] args)
{

    Print1();
    for (int i = 0; i < 1000; i++)
    {
        Console.Write(0);
    }
    Console.Read();
}

static void Print1()
{
    for(int i =0; i< 1000; i++)
    {
        Console.Write(1);
    }
}

image

  1. 在c#中启动多线程需要实例化一个 Thread 对象,然后在构造方法中传入一个lambda方法 , 所有被这个lambda方法所包裹起来的代码片段就属于新的线程中所需要执行的任务。
  2. 最后,调用Thread 对象 t.Start() 来启动新线程。
  3. 好的,现在,我们的代码中就出现了两个线程了一个是main方法中的打印0的主线程,而另一个责任 thread对象所包裹的方法 print1.
static void Main(string[] args)
{
    Thread t = new Thread(() => {
        Print1();
    });
    t.Start();

    for (int i = 0; i < 1000; i++)
    {
        Console.Write(0);
    }

    Console.Read();
}

static void Print1()
{
    for(int i =0; i< 1000; i++)
    {
        Console.Write(1);
    }
}

image

这次0和1的显示交织在一起了。为什么会交替显示0和1呢,这就是因为CPU在执行线程的时候是两个线程轮流执行的,每次执行几十毫秒,每次打印几十个字符,然后就离开当前线程去执行第二条线程了,在执行几十毫秒以后又会回到第一条线程,继续执行没有完成的任务。如此反复,知道两条线程全部结束,然后main方法结束以后,程序也就结束了。

而c#的线程中还有一个会常用的方法叫做sleep,可以让时间暂停,我们可以使用这个方法让线程进入静默状态。比如,我们有一个for循环,执行10次,打印数字0123456789。不过我希望每隔一秒打印一次,那么我们就可以使用Thread.Sleep,参数填入睡眠时间 1000 毫秒。

for (int i = 0; i < 10; i++)
{
    Console.WriteLine(num);
    Thread.Sleep(1000);
}

3. 前台线程、后台线程与线程池托管

3.1 代码

static void main(string[] args)
{
	var thread = new Thread(PrintHello);
	thread.Start();
	Console.WriteLine("退出主程序");
}
static void PrintHello()
{
    while (true)
    {
        Thread.Sleep(1000);
        Console.WriteLine($"hello from printHello");
    }
}

image

也就是说虽然主线程结束了,但子线程还在持续运行。那么这种情况下,持续运行的子线程就被称作前台线程,foreground thread。一般来说,在前台线程中执行的任务都比较重要,所以,只有等待所有的前台线程结束以后程序才能关闭。

既然由前台线程,那相对于的我们也有后台线程。后台线程执行的任务就不是那么重要了,只要主程序结束,就算后台线程的任务还没完成,也会强行打断直接退出。

在 .net 中,除了主线程以外,任何一个线程都可以随意在任何时候在前台和后台之间随意切换。我们只需要在线程t运行之前,调用它的属性IsBackground,把这个属性设置为true,那么这个线程就可以被切换为后台线程了。
thread.IsBackground = true;
image

3.2 前台线程和后台线程

那么前台线程和后台线程有什么区别和联系呢?一般前台线程用于需要长时间等待的任务,比如监听客户端的请求;后台线程一般用于处理时间较短的任务,比如处理客户端发过来的请求信息。

  • 后台线程不会阻止进程的终止。属于某个进程的所有前台线程都终止后,该进程就会被终止。所有剩余的后台线程都会停止且不会完成。
  • 可以在任何时候将前台线程修改为后台线程,方式是设置Thread.IsBackground 属性。
  • 不管是前台线程还是后台线程,如果线程内出现了异常,都会导致进程的终止。
  • 托管线程池中的线程都是后台线程,使用new Thread方式创建的线程默认都是前台线程

3.3 线程池

for(int i = 0; i < 100; i++)
{
    var thread = new Thread(() =>
    {
        Console.WriteLine($"循环次数 {i}, 线程id {Thread.CurrentThread.ManagedThreadId}");
    });
    thread.Start();
}

image
这样的线程创建方式当然没有问题,但是,要知道线程是非常消耗资源的,我们在for循环中创建100个线程电脑还承受的了,但如果for循环的次数是1千次1万次甚至是100万次呢?那么估计你的电脑硬件也受不了这么大的资源消耗。

对于这种情况我们就需要使用线程池来管理线程了。

  • 只要是通过线程池创建的线程,那么它的所有的生命周期都会托管在线程池内。
  • 所有线程池中的线程都是后台线程,线程池会检查程序当前的运行状态,
  • 当程序启动时,线程池内的线程也会开始工作; 而当main方法结束以后,线程池会自动关闭,同时中断所有正在运行的线程。
for(int i = 0; i < 100; i++)
{
	ThreadPool.QueueUserWorkItem((o)=>{
	Console.WriteLine($“当前运行线程: {Thread.CurrentThread.ManagedThreadId}”);
	})
}

image

线程id出现重复了,而线程id似乎没怎么增长过,总是重复使用那么几个id。

其实,这就是线程池的厉害之处,他可以在不新建线程的情况下重复使用已经完成的线程,这样可以保证线程维持在一个很低的水平,极大的节约我们的硬件资源。

但是从输出上来看,线程只执行了2、30次,这是为什么呢?这其实就是线程池的第二个特点,线程池中的线程全部默认为后台线程,所以,只要主程序、主线程结束,那么程序就直接退出了、线程池会自动关闭,线程池内的线程也会全部强制结束。

因为我们的main方法的执行时间比较短,线程池内的线程还没来得及运行就被强制结束了。所以,在命令行中的输出中我们只能看到二三十个线程的信息。

如果我们在程序结束前加上个Thread.Sleep等待5秒,

Thread.Sleep(5000);
Console.WriteLine("退出主程序");

由于等待了5秒,线程池有足够的时间执行所有任务,所有,命令行输出了100条线程信息。而且,我们能清楚看到线程id由重复的情况,也就是说线程可以在线程池内重复使用。
image

关于线程的创建,我们可以手动创建线程、也可以使用线程池来创建。手动创建的线程默认为前台线程,而线程池内的线程默认为后台线程。对于重要、并发量小的线程,我们需要手动来创建和管理,而对于并发量大、而又不那么重要的线程我们最好托管在线程池中。

4. 结束线程与CancellationToken

static void Main(string[] args)
{
    var thread = new Thread(PrintHello);
    thread.Start();

    Console.WriteLine("退出主程序");
}

static void PrintHello()
{
    while (true)
    {
        Thread.Sleep(100);
        Console.WriteLine($"hello from printHello");
    }
}

每隔一秒就会输出一次hello,永远都不会停止。如果我希望在不把PrintHello切换为后台线程的前提下,实现主程序结束的时候,同时关闭子线程,那我们该如何处理呢?

不管程序有多少个线程,程序内所有的资源都是被这些线程所共享。

不过,C#对线程取消的代码做了一个高层次的抽象,把整个任务的取消方式通过令牌token的形式封装成为了一个更加通用的取消机制,这就是,CancellationToken。这个CancellationToken不仅可以使用在多线程中,也可以用来取消异步操作。

static void Main(string[] args)
{
    //第一步创建CancellationTokenSource
    var cancelToken = new CancellationTokenSource();

    var thread = new Thread(() => PrintHello(cancelToken.Token));
    thread.Start();

    //模拟下载文件操作 5s
    Thread.Sleep(5000);
    Console.WriteLine("文件下载完成");
    
	//关闭子线程
    cancelToken.Cancel();

    Console.WriteLine("退出主程序");
}

static void PrintHello(CancellationToken stoppingToken)
{  
    //IsCancellationRequested通过这个属性来判断stoppingToken状态是否发送变化
    while (!stoppingToken.IsCancellationRequested)
    {
        Thread.Sleep(1000);
        Console.WriteLine($"hello from printHello");
    }
}

image

希望子线程PrintHello,在文件下载完成的3秒后才结束
cancelToken.CancelAfter(3000);

5. Join 与IsAlive

question:如果主线程的任务依赖于子线程的计算结果,

举例

static void Main(string[] args)
{
    var thread = new Thread(() => PrintHello());
    thread.Start();
    //刚好执行完,但是如果子线程不是固定的执行时间
    Thread.Sleep(10000);
    Console.WriteLine("文件下载完成");
    Console.WriteLine("退出主程序");
}

static void PrintHello()
{
    int i = 0;
    while (i++ < 10)
    {
        Thread.Sleep(1000);
        Console.WriteLine($"hello from printHello");
    }
}

while中换成Thread.Sleep(new Random().Next(100, 1000));

5.1 使用join

当我们在main方法中调用了join方法以后,主线程就和子线程绑定起来了,这个时候主线程就会被挂起,就会停在这里,等待子线程执行完毕以后,主线程接收到子线程执行完毕的通知以后,才会继续执行。

//Thread.Sleep(5000);
thread.Join();

5.2 IsAlive

其实还有一个比较笨的方法来探测子线程的执行状态。那就是使用一个while循环来判断线程的 IsAlive 属性。

bool check = true;
while (check)
{
    if (thread.IsAlive)
    {
        Console.WriteLine("子线程工作中");
        Thread.Sleep(100);
    } else
    {
        Console.WriteLine("子线程完成");
        check = false;
    }
}

//或者
while(thread.IsAlive)
{
	 Console.WriteLine("子线程工作中");
         Thread.Sleep(100);
}

6. 资源竞争与线程锁lock

static void Main(string[] args)
{
    for (int i = 0; i < 10; i++)
    {
        var thread = new Thread(AddText);
        thread.Start();
    }
}

static void AddText()
{
    File.AppendAllText(@"D:\test.txt", $"start ");
    Thread.Sleep(100);
    File.AppendAllText(@"D:\test.txt", $"end ");
}

Visual studio提示我们,IO操作异常,文件test.txt正在被另一个进程所使用。
image

发生这种错误的原因就是不同的线程之间发生了资源的争抢,谁都想打开test.txt文件,谁都想往这个文件中写入数据。所有线程全部堆在一起,产生了恶行竞争。最后,谁都没办法打开文件,导致系统崩溃。

这种现象就叫做资源竞争。资源竞争在多线程编程中是一个非常常见问题,最严重的情况就是现在这种系统直接崩溃。

那么,避免这种资源的恶性竞争的关键就在于文件必须每次是能由一个线程打开,当前线程操作文件的时候,其他线程需要排队等待。在c#中,有一个关键词叫做lock,可以帮我们锁定资源来排除资源竞争的现象。

static  object lockedObj  = new object();

static void Main(string[] args)
{
    for (int i = 0; i < 10; i++)
    {
        var thread = new Thread(AddText);
        thread.Start();
    }
}

static void AddText()
{
    lock(lockedObj)
    {
        File.AppendAllText(@"D:\test.txt", $"start_{Thread.CurrentThread.ManagedThreadId} ");
        Thread.Sleep(100);
        File.AppendAllText(@"D:\test.txt", $"end_{Thread.CurrentThread.ManagedThreadId} ");
    }
}

写入成功
image

7.Async

image
image
image
进程多了会导致死锁阻塞
image
不可能买那么多机器
image
image
image

8. 异步编程

8.1 原版

static void Main(string[] args)
{
    Calculate();
    Console.Read();
}

static void Calculate()
{
    Calculate1();
    Calculate2();
    Calculate3();
}

static int Calculate1()
{
    var result = 3;
    Console.WriteLine($"Calculate1: {result}");
    Thread.Sleep(2000);
    return result;
}

static int Calculate2()
{
    var result = 4;
    Console.WriteLine($"Calculate2: {result}");
    Thread.Sleep(3000);
    return result;
}

static int Calculate3()
{
    var result = 5;
    Console.WriteLine($"Calculate3: {result}");
    Thread.Sleep(1000);
    return result;
}

在c#中我们可以使用 Task 类来创建异步的并行逻辑。

8.2 异步

我们使用异步来处理并行,而多线程用来处理并发。Task处理异步、Thread处理多线程,Task的性能会比Thread高出不少。

Task 的使用方式于Thread也非常相似,使用类 Task, 调用方法 Run,方法参数传入一个lambda表达式,表达式内部的逻辑全部都属于异步逻辑。

//经过异步处理以后,我们的程序免去了单独等待每个计算的时间成本,运行效率大大提升了。,几乎同时完成
static void Calculate()
{
    Task.Run(() =>
    {
        Calculate1();
    });
    Task.Run(() =>
    {
        Calculate2();
    });
    Task.Run(() =>
    {
        Calculate3();
    });
}

上面的的改动都是基于Calculate1、2、3这三个方法之间没有依赖关系。一旦异步逻辑之前出现了依赖关系,我们就不能再连续使用三个异步逻辑了。

8.3 异步有依赖关系

static int Calculate3(int a, int b)
{
    var result = a + b;
    Console.WriteLine($"Calculate3: {result}");
    Thread.Sleep(1000);
    return result;
}

我们可以使用 Task.WaitAll 方法,等待task1和task2。等会程序在执行过程中会在这一句停下来,等待3秒左右,等待task1和task2完成,然后再继续下面的代码。

static void Calculate()
{
    var task1 = Task.Run(() =>
    {
        return Calculate1();
    });
    var task2 = Task.Run(() =>
    {
        return Calculate2();
    });

    Task.WaitAll(task1, task2);
    var awaiter1 = task1.GetAwaiter();
    var awaiter2 = task2.GetAwaiter();

    var result1 = awaiter1.GetResult();
    var result2 = awaiter2.GetResult();

    Calculate3(result1, result2);
}

如果,我的Calculate2依赖于Calculate1的计算结果

OnCompleted,这个方法其实就是一个回调方法,当task1执行完成以后,OnCompleted方法就会被激活,运行OnCompleted方法内部的代码。

static void Calculate()
{
    var task1 = Task.Run(() =>
    {
        return Calculate1();
    });
    var awaiter1 = task1.GetAwaiter();
    awaiter1.OnCompleted(() =>
    {
        var result1 = awaiter1.GetResult();

        var task2 = Task.Run(() =>
        {
            return Calculate2(result1);
        });
        var awaiter2 = task2.GetAwaiter();
        awaiter2.OnCompleted(() =>
        {
            var result2 = awaiter2.GetResult();
            Calculate3(result1, result2);
        });
    });
}

接下来看这样复杂的操作怎么简化见9

9. c#的异步async/await

image
image
注意:异步方法只有这四种返回类型
image
image
image
image
image

10. async/await操作

  • 按照c#的行业规范,所有的异步方法我们都需要给他加上Async后缀,当然这只是行业规范不是硬性要求。
  • 然后,加上async关键词,然后返回类型改为Task,泛型为计算结果的类型,整数类型。
  • 而我们的等待,thread sleep其实并不是异步,而是多线程,所以,我们最好把这里的thread sleep换成异步的等待逻辑,使用Task.Delay 方法。
  • 而Task.Delay 本身就是一个异步操作,所以,我们可以使用await关键词来进行引导。
static async Task<int> Calculate1Async()
{
    var result = 3;
    Console.WriteLine($"Calculate1: {result}");
    await Task.Delay(2000);
    return result;
}

既然需要使用aysnc方法,那么我们的调用方法Calculate,也必须为async,否者不能使用await关键词。
Calculate2Async 和Calculate3Async也进行修改
最后Calculate

static async void Calculate()
{
	var result1 = await Calculate1Async();
	var result2 = await Calculate2Async(result1);
	var result3 = await Calculate3(result1, result2);
	Console.WriteLine($"最终答案 : {result3}");
}

image
与上一节繁琐的实现效果相同

11. Task vs Thread

异步不是是多线程。异步用来处理并行,多线程用来处理并发。

11.1 比较

image
创建100个task的时间:

static void TaskTest()
{
    //创建计时器
    var sw = new Stopwatch();
    sw.Start();
    for (int i = 0; i < 100; i++)
    {
        Task.Factory.StartNew(() => { });
    }
    sw.Stop();
	//输出计时时间
    Console.WriteLine("Task " + sw.ElapsedMilliseconds);
}

创建100个thread的时间:

static void ThreadTest()
{
    var sw = new Stopwatch();
    sw.Start();
    for (int i = 0; i < 100; i++)
    {
        new Thread(() => { }).Start();
    }
    sw.Stop();
    Console.WriteLine("Thread " + sw.ElapsedMilliseconds);
}

执行测试

static void Main(string[] args)
{
    TaskTest();
    ThreadTest();

    Console.Read();
}

单位毫秒
image

11.2 原因

接下来说明为什么效率差距这么大

static void DoSomething()
{
    Console.WriteLine("异步 Task 开始");
    Task.Delay(60000).Wait();//打断点
    Console.WriteLine("异步 Task 结束");//打断点
}
  • 可以看到我们的程序在创建task之前停下来了,目前整个程序只有一个线程,就是这个主线程。
  • 按下继续执行的按钮,继续执行task的操作,正常来说,程序会为我们创建一个子线程用于同步执行task delay的任务
  • 在创建异步逻辑以后,并没有新建任何子线程,我们的程序依然只有一个主线程。
    image
  • 第二个断点到了,异步逻辑结束。
  • 这个线程的作用是 resuming asyn method,恢复异步方法。而这个线程的唯一作用就是通知主程序异步结束,就等于是在告诉主程序,“嘿我搞完了,该你了”。主程序收到通知以后,会按照通知内容继续执行后续代码。
    image

虽然Task和Thread看起来很像,但是异步并不是多线程,异步不会在后台创建新的线程。从原理上来说,异步的运作机制是方法的回调,而不是多线执行。

所以,可以看到当我们使用了task以后,主线程继续运行,而同时开出一条分支来执行task中的任务。在这个过程中,主线和分支的逻辑过程是不同步的、是分别发生,非同步、分别执行则也正是异步这个词的由来。

多线程是并发操作,互相独立,所以不会通知。而异步依赖于主线程,必须在异步工作完成以后通知主程序,而这个异步通知的过程我们就叫做“回调 callback”。
所以,当异步结束以后,自动创建的这个工作线程的唯一工作就是回调,并非处理真正的异步逻辑。所以这个线程非常轻量级,轻到可以忽略不计的地步。

12. 并行与并发

参考资料:
https://talks.golang.org/2012/waza.slide#1
https://blog.csdn.net/alex_yangchuansheng/article/details/102933264

现在我们都说设计可并行、高并发的程序,而且我们很多时候会在潜意识里觉得自己对并行(Parallelism)和并发(Concurrency)的区别很清楚,但如果要明确的说出二者的区别,又感觉没办法给出一个非常清晰的描述。

那么什么是并发?什么又是并行呢?并行的概念比较简单,并行总是和执行(executions)相关,很多东西同时执行就是并行;而并发则是通过一些方式组织你的程序,让它可以分成多个模块去独立的执行并行必然是需要多核的,一个处理器是无法并行的;但并发和处理器并没有什么必然联系,在一个处理器上面,我们的程序也可以是并发的。

举个简单的例子,华罗庚泡茶,必须有烧水、洗杯子、拿茶叶等步骤。现在我们想尽快做完这件事,也就是“一共要处理很多事情”,有很多方法可以实现并发,例如请多个人同时做,这就是并行。并行是实现并发的一种方式,但不是唯一的方式。我们一个人也可以实现并发,例如先烧水、然后不用等水烧开就去洗杯子,所以通过调整程序运行方式也可以实现并发。

posted @ 2023-11-03 13:20  huihui不会写代码  阅读(179)  评论(0)    收藏  举报