S1-03 线程间通讯

回顾

上节课中我们学了任务的传参方式,分别试验了单参数和多参数的传入,同时多参数又分为按数组传入和按结构体传入两种方式。
第二部分知识点是ESP32-S3的多核心知识,ESP32-S3有两个核心,核心0用于运行系统相关的协议栈,核心1是留给我们用的,但其实任意一个核心我们都可以使用,为了保证系统的稳定性,建议只使用核心1,当核心1的资源不够时,在考虑核心0,而制定任务运行在哪个核心的函数是 xTaskCreatePinnedToCore
任务包含有物种状态,分别是 就绪态,运行态,挂起态,阻塞态和待删除。
任务运行结束的时候,必须显示的调用vTaskDelete来删除程序,否则会造成溢出错误。

本节课的学习任务

  1. 使用全局变量完成任务间的通讯
  2. 使用Mutex保证全局变量的一致性(线程安全)
  3. 互斥信号量的创建与管理
  4. U8G2接入点亮OLED小屏幕

关于print输出的安全性

在之前的例程中,我们尽可能的避开了在多任务中使用串口输出,因为在Android中,串口是非线程安全的,尤其是在输出后没有刷新的情况下(使用print,而不是println),很容易造成数据混乱或内存溢出,通过实验我们可以验证。

void task1(void *param_t){
  while(1){
    Serial.print("数字:");
    Serial.print(1);
    Serial.print("\t\t");
    Serial.println("第一个线程正在运行中...");
    vTaskDelay(pdMS_TO_TICKS(random(10,100)));
  }
}
void task2(void *param_t){
  while(1){
    Serial.print("数字:");
    Serial.print(2);
    Serial.print("\t\t");
    Serial.println("第二个线程正在运行中...");
    vTaskDelay(pdMS_TO_TICKS(random(10,100)));
  }
}
void setup() {
  Serial.begin(115200);
  Serial.println("启动两个线程");
  xTaskCreate(task1, "TASK-1", 1024, NULL, 1, NULL);
  xTaskCreate(task2, "TASK-1", 1024, NULL, 1, NULL);
}
void loop() {
  delay(10);
}

这段程序中,两个线程同事用到了串口输出,在这种情况下导致了串口缓冲区的数据混乱,最后溢出,MCU重启。
那为什么会造成这种原因呢?
这就涉及到了线程安全的问题。

吃货和厨子的故事

我们假设有一个场景,一个冰箱里放了 100 个汉堡,冰箱旁有两个人,一个是吃货,不停的吃,另一个是厨子,隔一段时间公布一下冰箱里剩余的汉堡数量和被吃货吃掉的汉堡数量。
我们可以把厨子和吃货看成两个任务,这两个任务需要共同访问两个变量(剩余量和吃掉的数量),所以这两个变量肯定不能作为某个任务的私有变量存在,必须放在公共区域,做成全局变量。

代码共享位置:https://wokwi.com/projects/362488867969426433

volatile int16_t quantity = 100;   // 食物的剩余数量
volatile int16_t eaten = 0;        // 吃掉的食物累计
// 吃货线程
void foodie_task(void *param_t){
  while(1){
    int16_t qua = quantity;    // 取得剩余库存
    vTaskDelay(pdMS_TO_TICKS(random(10,100)));
    if(quantity>0){
      // 吃掉食物
      quantity = qua-1;
      eaten ++;
    }else{
      // 没有实物了,吃货离开
      Serial.println("没食物了,我走了!");
      vTaskDelete(NULL);
    }
  }
}
// 厨师,随时查看食物的剩余情况
void chef_task(void *param_t){
  while(1){
    printf("食物剩余量 : %d\n", quantity);
    printf("吃掉的食物 :%d\n", eaten);
    if(quantity<=0){
      printf("---=== 店铺打烊 ===---\n");
      printf("被吃掉的食物 :%d\n", eaten);
      printf("---------------------\n");
      vTaskDelete(NULL);
    }
    vTaskDelay(pdMS_TO_TICKS(100));
  }
}
void setup() {
  Serial.begin(115200);
  xTaskCreate(foodie_task, "Foodie", 1024*4, NULL, 1, NULL);
  xTaskCreate(chef_task, "Chef", 1024*4, NULL, 1, NULL);
}
void loop() {
  // put your main code here, to run repeatedly:
  delay(10); // this speeds up the simulation
}

在这个例程中, foodie_task 是吃货的线程,每次他先拿到剩余汉堡的数量,然后吃掉食物,并对这个变量进行减一操作,在拿到剩余量和会写剩余量中间,我们加了一个随机的延迟,用于模拟同一个变量的读写异步操作。当吃货发现冰箱里的汉堡剩余量为0的时候,就走开了。
另一个任务 chef_task 是厨子的任务,他每间隔100ms报告一次冰箱里剩余汉堡的数量,以及被吃货吃掉汉堡的数量。当冰箱里食物为0的时候,店铺打样,厨子也不再数了,并且报告一下今天的战果。
这个例程中使用全局变量的方式模拟了两个线程间的通讯,非常简单。

volatile 修饰符

volatile 是一个类型限定修饰符,表示这个变量是“易改变的”或“易失的”。
在C程序中,编译器为了提高效率和代码执行速度可能会对某些语句进行优化,比如移除多余的读写操作或者缓存变量值。然而有些变量的值可能在程序执行期间被外部因素修改,比如硬件寄存器、中断等等。使用volatile限定符可以告诉编译器不要对这些变量进行优化,否则可能会导致程序出现未知的错误或者异常行为。
例如,在使用指针操作内存映射I/O时(即通过内存读写操作控制硬件),为防止编译器在读取或写入操作过程中做优化误认为数据没有发生变化,需要在声明这个指针类型的变量之前使用 volatile 限定符告诉编译器这个变量绝对不能被优化处理。

ESP32中任务间的数据传输

ESP32是32位的CPU,也就是说在ESP32各总线间传输指令和数据的时候都采用了32位的长度,也就是每个完整指令和数据包必须是32字节,超出部分将分为两次操作。
我们上一个例程中使用的是uint16,2字节长度的数据,在ESP32数据传输中会被自动补齐,但如果我们传输的指令是longlong类型的(在ESP32中是8字节64位),此时数据传输就会被分为两次,而在这两次传输过程中有可能会被打断。
事项一下这样一个场景:
有A B两个任务在操作同一个64位的变量,A负责读,B负责写:
当B的优先级比A高,又恰巧A限制性B后执行;
当A读取前面4个字节后(这是一次完整的OP),B线程被调度到前台,开始修改变量,因为B优先级比A高,所以A必须等待B修改完之后才能读取下面半截数据;
但其实当A再次读取的时候,已经注定了数据是错误的!

问题来了——多吃的汉堡是从哪来的?!

这时候,店铺中又来了个吃货,于是,程序变成了这样:
代码共享位置:https://wokwi.com/projects/362489968376591361

volatile int16_t quantity = 100;   // 食物的剩余数量
volatile int16_t eatenCount=0;     // 总共吃掉的实物数量
// 吃货线程
void foodie_task(void *param_t){
  int16_t eaten = 0;        // 吃掉的食物累计
  while(1){
    int16_t qua = quantity;    // 取得剩余库存
    vTaskDelay(pdMS_TO_TICKS(random(10,100)));
    if(quantity>0){
      // 吃掉食物
      quantity = qua-1;
      eaten ++;
      eatenCount++;
    }else{
      // 没有实物了,吃货离开
      printf("------> 没食物了,我一共吃掉: %d个\n",eaten);
      vTaskDelete(NULL);
    }
  }
}
// 厨师,随时查看食物的剩余情况
void chef_task(void *param_t){
  while(1){
    printf("食物剩余量 : %d\n", quantity);
    printf("吃掉的食物 :%d\n", eatenCount);
    if(quantity<=0){
      printf("---=== 店铺打烊 ===---\n");
      printf("被吃掉的食物 :%d\n", eatenCount);
      printf("-----------------------\n");
      vTaskDelete(NULL);
    }
    vTaskDelay(pdMS_TO_TICKS(100));
  }
}
void setup() {
  Serial.begin(115200);
  xTaskCreate(foodie_task, "Foodie1", 1024*4, NULL, 1, NULL);
  xTaskCreate(foodie_task, "Foodie2", 1024*4, NULL, 1, NULL);
  xTaskCreate(chef_task, "Chef", 1024*4, NULL, 1, NULL);
}
void loop() {
  // put your main code here, to run repeatedly:
  delay(10); // this speeds up the simulation
}

程序基本没有变化,但当两个吃货一起吃汉堡的时候,我们发现本来就有100个汉堡,但两个人一起吃掉的汉堡数量远比这个多。
问题就出在全局变量上。
上一个例程中,我们的全局变量只有一个任务在读写,另一个任务只参与了读,而没有回写过数据;而在这个历程中,有两个任务同时参与的任务的读写。
当吃货1 读取冰箱的汉堡剩余量后(int16_t qua = quantity;)并不是立即减一会写的,而是随机等待了一段时间,这段时间是我们模拟的,在实际项目开发中这种等待非常普遍。等待一段时间后,对取出的变量-1,然后再回写到 quantity 中。在等待的过程中,其实吃货2 也运行了 int16_t qua = quantity; 而吃货2拿到的剩余量是吃货1吃之前的剩余量,还没来得及把更新的数据写回去。
注意: eatenCount++; 这条语句是不熟影响的,因为自增和自检操作在ESP32中是个原子级的操作,不会被中间打断,但并不是所有变量的操作我们都可以使用原子操作的。
所以最后我们得到的结果是,只有100个汉堡,但却被这两个吃货吃掉了将近200个。

在正式的项目开发中,多任务操作公共变量的问题经常发生,所以这是一个普遍问题,我们需要认真解决。

解决吃货问题

如果想解决两个吃货同时开冰箱吃汉堡的问题,最简单的方法就是给冰箱加把锁,而这把锁只有一把钥匙,当吃货们想吃东西的时候,必须先找到钥匙开锁,吃完后再把钥匙放回去,这样方便其他吃货使用。
代码共享位置:https://wokwi.com/projects/362491831377480705

volatile int16_t quantity = 100;   // 食物的剩余数量
volatile int16_t eatenCount=0;     // 总共吃掉的实物数量
SemaphoreHandle_t key = NULL;     // 冰箱的钥匙
TickType_t timeOut = 1000;        // 等待拿钥匙的超时时间
// 吃货线程
void foodie_task(void *param_t){
  int16_t eaten = 0;        // 吃掉的食物累计
  while(1){
    // 尝试在1秒钟之内拿到钥匙,如果不成功则放弃
    // 如果成功拿到钥匙,则开始对冰箱内的食物进行操作
    // 但吃完食物之后记得把钥匙放回去
    if(xSemaphoreTake(key, timeOut) == pdPASS){
      int16_t qua = quantity;    // 取得剩余库存
      vTaskDelay(pdMS_TO_TICKS(random(10,100)));
      if(quantity>0){
        // 吃掉食物
        quantity = qua-1;
        eaten ++;
        eatenCount++;
        xSemaphoreGive(key);    // 吃完后把钥匙放回去
      }else{
        // 没有实物了,吃货离开
        xSemaphoreGive(key);    // 如果冰箱里没实物了,也记得把钥匙放回去
        printf("------> 没食物了,我一共吃掉: %d个\n",eaten);
        vTaskDelete(NULL);
      }
    }else{
      printf("没拿到钥匙...\n");
    }
  }
}
// 厨师,随时查看食物的剩余情况
void chef_task(void *param_t){
  while(1){
    printf("食物剩余量 : %d\n", quantity);
    printf("吃掉的食物 :%d\n", eatenCount);
    if(quantity<=0){
      printf("---=== 店铺打烊 ===---\n");
      printf("被吃掉的食物 :%d\n", eatenCount);
      printf("-----------------------\n");
      vTaskDelete(NULL);
    }
    vTaskDelay(pdMS_TO_TICKS(100));
  }
}
void setup() {
  Serial.begin(115200);
  key = xSemaphoreCreateMutex();    // 创建一个互斥信号量
  printf("%X",portMAX_DELAY );
  xTaskCreate(foodie_task, "Foodie1", 1024*4, NULL, 1, NULL);
  xTaskCreate(foodie_task, "Foodie2", 1024*4, NULL, 1, NULL);
  xTaskCreate(chef_task, "Chef", 1024*4, NULL, 1, NULL);
}
void loop() {
  // put your main code here, to run repeatedly:
  delay(10); // this speeds up the simulation
}

这这个例程中,在setup函数中我们通过 xSemaphoreCreateMutex 创建了一个互斥信号量,并将它赋给了一个叫key的句柄,这就是我们为冰箱创建的这把锁。
Mutex是互斥信号量,互斥的意思是同时只能有一个人拥有,如果第二个人过来想取走这个信号量,那有两种方式,一种是等待,一种是放弃,如果选择等待,则会有一个超时的时间限制,因为不可能无休止的等待下去,这样的话CPU就直接歇菜了。
向获得这把钥匙,我们使用的是 xSemaphoreTake 函数,函数传入两个变量,第一个是互斥信号量的句柄,也就是我们之前创建的那个key,第二个是等待的超时时间,例程中设置的是1000个Tick(注意,这里不是毫秒,而是系统Tick,如果需要使用毫秒,可以用pdMS_TO_TICKS进行运算),如果在固定时间内获得了信号量,则返回pdPASS,如果返回的是pdFalse或其他的值,则表示获取失败。
如果需要长时间等待,可以尝试使用 portMAX_DELAY 作为第二个参数,但这个参数也不代表这无休止的等待,portMAX_DELAY 的值是 0xFFFFFFFF,十进制表示为4294967295,按照我们1ms的tick计算,使用这个参数可以一直等待50天,但50天之后仍然必定会超时。
在我们例程中这样写是没问题的,但在实际项目中不推荐这样写,因为我们实际的产品运行绝对必定一定会超过50天(这点信心都没有就别做物联网了),我们程序需要的是确定性,而不是这样模棱两可的答案,所以我能在获取信号量的时候一定要加判断,是否为pdPASS。

如果能够正常的获取信号量,我们着这段时间对变量的任何操作都可以视为是线程安全的,前提是所有线程中都是用同一个互斥信号量对相同的变量进行保护,只有有一个任务放水了,那一切都是徒劳的。

当公共资源操作完毕之后,使用 xSemaphoreGive 将信号量还回去,待其他线程使用。

对于互斥信号量的操作,有以下几点需要注意:

  1. 一个互斥信号量保护同一组公共资源,不同的公共资源可以有多个信号量保护;
  2. 同一个公共资源尽量使用一个互斥信号量进行保护;
  3. 获取互斥信号量必须在操作资源之前,并且保证信号量获取是成功的才可以操作资源;
  4. 资源使用完毕后立刻将信号量释放,确保其他任务顺利使用;
  5. 信号量的获取和释放中间的操作尽可能的简单,尽可能只是对公共资源的操作,尽可能不掺杂其他操作;
  6. 千万不要在中断中使用信号量,当然大多数的系统也做了这方面的防范。
  7. 在锁中严格禁止使用delay相关函数
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值