Published on

Concurrency & RTOS Fundamentals (ESP32 Context)

Authors

📘 Foundation Article 2: Concurrency & RTOS Fundamentals (ESP32 Context)

Track: C++ Firmware Engineering Foundations for ESP32 Environment: ESP32 + VSCode + Arduino Community Edition Project Base: IndustrialNode/IndustrialNode.ino Use Case: Generic Sensor + Relay Node



1. Mengapa Concurrency Itu Default di ESP32

Pada mikrokontroler klasik:

main()
while(true)

Satu stack. Satu eksekusi. Satu flow.

Pada ESP32:

  • loop() = FreeRTOS task
  • WiFi = task
  • TCP/IP = task
  • Timer service = task
  • IDLE task = selalu ada
  • ISR = interrupt context

Artinya:

Bahkan sebelum Anda membuat arsitektur, sistem sudah concurrent.

Ini bukan pilihan desain Anda.

Ini adalah kondisi dasar sistem.


Diagram Model Eksekusi Dasar (IndustrialNode Context)

Core 1:
  ├─ loopTask (control + sensor read)
  ├─ user task (jika dibuat)
  └─ IDLE

Core 0:
  ├─ WiFi task
  ├─ TCP/IP task
  ├─ Timer task
  └─ IDLE

Interrupt:
  └─ ISR button / peripheral

Tidak ada bagian firmware yang benar-benar sendirian.


Kenapa Ini Penting Secara Arsitektural?

Karena tanpa sadar concurrency:

  • Anda akan memakai global mutable state.
  • ISR akan mengubah state langsung.
  • MQTT callback akan menyentuh driver langsung.
  • Anda akan menyalahkan hardware saat race muncul.

Concurrency awareness bukan teori.

Itu fondasi untuk:

  • Dependency discipline
  • Memory discipline
  • Layering discipline
  • Reliability model

2. Apa Itu Task (Dalam Konteks Firmware)

Task adalah:

  • Fungsi dengan stack sendiri
  • Memiliki prioritas
  • Dijadwalkan oleh scheduler
  • Bisa Running / Ready / Blocked

Dalam Arduino ESP32:

void loop()

Adalah task bernama loopTask.


Setiap Task Memiliki:

1️⃣ Stack sendiri 2️⃣ Context register sendiri 3️⃣ Prioritas relatif 4️⃣ Scheduling slot

Implikasi nyata:

  • Stack overflow tidak terlihat seperti crash biasa.
  • Blocking satu task tidak menghentikan sistem.
  • Prioritas salah bisa menyebabkan starvation.

Contoh Kasus Nyata (Stack Awareness)

Tambahkan ke loop:

char largeBuffer[4096];

Perhatikan:

  • uxTaskGetStackHighWaterMark(NULL) turun drastis.
  • Jika terlalu besar → reset.

Engineer sering mengira ini heap.

Padahal ini stack loopTask.

Concurrency + memory model mulai bertemu di sini.


🔬 Concurrency Practical Lab — Membuat Task Tambahan

Sekarang kita masuk praktikal.

Kita akan:

  • Membuat task sensor terpisah
  • Membuat queue
  • Mengirim event ke loopTask
  • Mengamati behavior

📂 Struktur Tetap Flat

IndustrialNode/
└── IndustrialNode.ino

Belum masuk layering. Fokus concurrency.


🧪 IndustrialNode.ino (Full Runnable Concurrency Lab)

Copy–paste utuh:

#include <Arduino.h>

// =============================
// Configuration
// =============================

constexpr int LED_PIN = 5;
constexpr int BUTTON_PIN = 18;

constexpr uint32_t SENSOR_TASK_STACK = 4096;
constexpr uint32_t CONTROL_PERIOD_MS = 200;

// =============================
// Queue Definition
// =============================

QueueHandle_t eventQueue;

enum class EventType : uint8_t {
    BUTTON,
    SENSOR
};

struct Event {
    EventType type;
    int value;
};

// =============================
// ISR
// =============================

void IRAM_ATTR buttonISR()
{
    Event ev;
    ev.type = EventType::BUTTON;
    ev.value = 1;

    BaseType_t xHigherPriorityTaskWoken = pdFALSE;
    xQueueSendFromISR(eventQueue, &ev, &xHigherPriorityTaskWoken);

    if (xHigherPriorityTaskWoken)
        portYIELD_FROM_ISR();
}

// =============================
// Sensor Task
// =============================

void sensorTask(void* param)
{
    while (true)
    {
        Event ev;
        ev.type = EventType::SENSOR;
        ev.value = analogRead(34);

        xQueueSend(eventQueue, &ev, portMAX_DELAY);

        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

// =============================
// Setup
// =============================

void setup()
{
    Serial.begin(115200);
    delay(1000);

    pinMode(LED_PIN, OUTPUT);
    pinMode(BUTTON_PIN, INPUT_PULLUP);

    eventQueue = xQueueCreate(10, sizeof(Event));

    attachInterrupt(digitalPinToInterrupt(BUTTON_PIN),
                    buttonISR,
                    FALLING);

    xTaskCreatePinnedToCore(
        sensorTask,
        "SensorTask",
        SENSOR_TASK_STACK,
        nullptr,
        1,
        nullptr,
        1
    );

    Serial.println("Concurrency Lab Started");
}

// =============================
// Loop (Control Task)
// =============================

void loop()
{
    Event ev;

    if (xQueueReceive(eventQueue, &ev, pdMS_TO_TICKS(CONTROL_PERIOD_MS)))
    {
        if (ev.type == EventType::BUTTON)
        {
            Serial.println("Button Event");
            digitalWrite(LED_PIN, HIGH);
        }
        else if (ev.type == EventType::SENSOR)
        {
            Serial.print("Sensor Value: ");
            Serial.println(ev.value);

            if (ev.value > 2000)
                digitalWrite(LED_PIN, HIGH);
            else
                digitalWrite(LED_PIN, LOW);
        }
    }
}

Apa yang Kita Lakukan?

  1. Membuat task tambahan (sensorTask).
  2. Membuat queue sebagai boundary.
  3. ISR tidak menyentuh LED.
  4. sensorTask tidak menyentuh LED.
  5. Hanya loop (control) yang menyentuh actuator.

Ini sudah:

  • Ownership model awal.
  • Concurrency boundary awal.
  • Determinism meningkat.

Tanpa OOP. Tanpa layering. Murni concurrency model.


3. Scheduler & Preemption

Sekarang kita analisis apa yang benar-benar terjadi.

Saat sensorTask delay:

vTaskDelay(...)

Ia masuk state Blocked.

Scheduler menjalankan task lain.

Jika ISR masuk:

  • ISR kirim event.
  • Jika controlTask blocked → bisa langsung wake.

Inilah preemption nyata.


Ilustrasi Preemption dalam Node Ini

SensorTask Running
vTaskDelay
ControlTask Running
        Button Interrupt
ISR
ControlTask Wake

Ini bukan ilustrasi textbook.

Ini yang terjadi di board Anda sekarang.


4. Shared State & Race Condition

Sampai titik ini kita sudah:

  • Punya sensorTask
  • Punya ISR
  • Punya loop() sebagai control task
  • Punya queue

Sekarang kita sengaja buat versi salah untuk melihat race.


Apa Itu Race Condition?

Race terjadi ketika:

  • Dua context berbeda
  • Mengakses resource yang sama
  • Tanpa koordinasi
  • Hasil bergantung timing scheduler

Race bukan error sintaks.

Race adalah error model eksekusi.


🔬 Race Lab — Versi Salah (Tanpa Queue)

Ganti seluruh program dengan ini:

#include <Arduino.h>

constexpr int LED_PIN = 5;
constexpr int BUTTON_PIN = 18;

volatile int sharedValue = 0;

void IRAM_ATTR buttonISR()
{
    sharedValue++;
}

void sensorTask(void* param)
{
    while (true)
    {
        sharedValue += 10;
        vTaskDelay(pdMS_TO_TICKS(100));
    }
}

void setup()
{
    Serial.begin(115200);
    delay(1000);

    pinMode(LED_PIN, OUTPUT);
    pinMode(BUTTON_PIN, INPUT_PULLUP);

    attachInterrupt(digitalPinToInterrupt(BUTTON_PIN),
                    buttonISR,
                    FALLING);

    xTaskCreatePinnedToCore(
        sensorTask,
        "SensorTask",
        4096,
        nullptr,
        1,
        nullptr,
        1
    );
}

void loop()
{
    if (sharedValue > 50)
    {
        digitalWrite(LED_PIN, HIGH);
    }
    else
    {
        digitalWrite(LED_PIN, LOW);
    }

    Serial.println(sharedValue);
    delay(200);
}

Apa yang Terjadi?

  • ISR menambah sharedValue
  • sensorTask menambah sharedValue
  • loop membaca sharedValue

Tidak ada proteksi.

sharedValue++ bukan atomik.

Secara internal:

Load
Add
Store

Jika interleave:

Task load
ISR load
Task store
ISR store

Nilai bisa hilang.

Ini race nyata.


Kenapa volatile Tidak Cukup?

volatile hanya memastikan:

  • Compiler tidak menyimpan nilai di register.
  • Setiap akses benar-benar baca memory.

volatile tidak membuat:

  • Operasi atomik.
  • Lock.
  • Critical section.

Ini salah kaprah umum.


Ilustrasi Race dalam Sensor + Relay Node

ISR Button → relayState = true
MQTT Callback → relayState = false
Control loop → relayState = sensorDecision

Tanpa ownership:

  • Relay bisa berkedip.
  • State tidak konsisten.
  • Bug sporadis.

Race tidak terlihat saat code review cepat.

Race muncul di runtime.


5. Queue sebagai Mekanisme Aman

Kita kembali ke versi benar (queue-based).

Konsep inti:

Jangan berbagi state. Kirim pesan.

ISR tidak boleh ubah actuator.

ISR hanya kirim event.


Diagram Model Aman (IndustrialNode)

ISR
Queue
Control Task
RelayDriver

Satu owner.

Satu domain yang menyentuh hardware.


Mengapa Queue Lebih Aman?

  • 1️⃣ Tidak ada shared mutable state lintas context
  • 2️⃣ ISR tidak menyentuh business logic
  • 3️⃣ Task tunggal mengontrol actuator
  • 4️⃣ Event ordering bisa dianalisis

Queue memindahkan problem dari:

  • Memory interleaving menjadi
  • Message ordering

Dan message ordering lebih deterministik.


🔬 Validasi: Perhatikan Behavior

Kembali ke Concurrency Lab sebelumnya.

Perhatikan:

  • ISR kirim event.
  • sensorTask kirim event.
  • loop yang memutuskan relay.

Tidak ada race.

Tidak ada sharedValue global.

Ownership jelas.


Kapan Queue Tidak Cukup?

Queue cocok untuk:

  • Event
  • Command
  • Signal

Queue tidak cukup untuk:

  • Shared SPI bus
  • Shared I2C bus
  • Shared file system
  • Shared large buffer

Di sinilah mutex masuk.


6. Mutex & Deadlock (Konsep Dasar)

Mutex adalah mekanisme locking.

Tujuan:

  • Mengamankan resource bersama.

🔬 Deadlock Lab Sederhana

Tambahkan dua mutex:

SemaphoreHandle_t mutexA;
SemaphoreHandle_t mutexB;

Buat dua task:

Task 1:

xSemaphoreTake(mutexA, portMAX_DELAY);
delay(10);
xSemaphoreTake(mutexB, portMAX_DELAY);

Task 2:

xSemaphoreTake(mutexB, portMAX_DELAY);
delay(10);
xSemaphoreTake(mutexA, portMAX_DELAY);

Jika timing tepat:

  • Task 1 pegang A.
  • Task 2 pegang B.
  • Task 1 tunggu B.
  • Task 2 tunggu A.

Deadlock.

Tidak crash.

Tidak error log.

Watchdog reset.

Engineer bingung.


Kenapa Deadlock Sering Tidak Disadari?

Karena:

  • Resource ownership tidak digambar.
  • Urutan lock tidak distandarkan.
  • Tidak ada rule sistemik.

Deadlock bukan bug kecil.

Deadlock adalah kegagalan desain.


Rule Mental di Foundation

Di tahap Foundation:

  • Hindari mutex jika bisa pakai queue.
  • Jangan lock lebih dari satu resource tanpa urutan tetap.
  • ISR tidak boleh mengambil mutex biasa.
  • Prioritaskan message passing.

Kita belum membekukan rule.

Kita membentuk mental model.


Sampai di sini kita sudah:

  • Membuktikan race.
  • Membuktikan volatile tidak cukup.
  • Membuktikan queue lebih aman.
  • Memahami deadlock secara praktis.

7. Sensor + Relay Node dalam Konteks Concurrency

Sekarang kita satukan semua yang sudah diuji:

  • ISR
  • sensorTask
  • loop sebagai control
  • Queue
  • Race scenario
  • Deadlock awareness

Kita tarik ke sistem nyata:

Generic Sensor + Relay Node


Arsitektur Tanpa Model Concurrency (Yang Sering Terjadi)

ISR Button        → relay.set()
MQTT Callback     → relay.set()
loop() sensor     → relay.set()

Semua context menyentuh actuator.

Dampak:

  • Relay chatter
  • State tidak konsisten
  • Sulit debug
  • Tidak deterministik
  • Scaling impossible

Masalahnya bukan relay. Masalahnya adalah:

Tidak ada ownership domain.


Arsitektur Dengan Model Concurrency Benar

ISR Button
   Queue
MQTT Callback
   Queue
Control Task (loop)
Relay Driver

Satu owner untuk actuator.

ISR tidak menyentuh hardware. MQTT callback tidak menyentuh hardware. Hanya ControlTask yang boleh.

Ini bukan OOP. Ini concurrency discipline.


🔬 Practical Validation — Tambahkan MQTT Simulation Task

Kita modifikasi program sebelumnya untuk mensimulasikan “network task”.

Tambahkan task berikut:

void networkSimTask(void* param)
{
    while (true)
    {
        Event ev;
        ev.type = EventType::BUTTON;
        ev.value = 999;  // simulate remote command

        xQueueSend(eventQueue, &ev, portMAX_DELAY);

        vTaskDelay(pdMS_TO_TICKS(3000));
    }
}

Tambahkan di setup:

xTaskCreatePinnedToCore(
    networkSimTask,
    "NetSim",
    4096,
    nullptr,
    1,
    nullptr,
    0
);

Sekarang sistem punya:

  • ISR → Queue
  • sensorTask → Queue
  • networkSimTask → Queue
  • loop → single control owner

Perhatikan behavior:

  • Tidak ada race.
  • Tidak ada direct actuator write.
  • Tidak ada shared mutable state global.

Kenapa Ini Penting Sebelum OOP?

Karena banyak engineer berpikir:

“Saya buat class saja, selesai.”

Salah.

Class tidak menyelesaikan race.

Encapsulation tidak mencegah deadlock.

Dependency injection tidak menyelesaikan interleaving.

Concurrency discipline harus lebih dulu.

OOP hanya alat untuk mengontrol dependency, bukan untuk memperbaiki race.


Contoh Praktis: Event Flag vs Queue (Perbandingan Nyata)

Model Flag (Riskan)

volatile bool buttonPressed = false;

void IRAM_ATTR buttonISR() {
    buttonPressed = true;
}

Masalah:

  • Masih shared state.
  • Scaling sulit.
  • Multiple event type sulit.
  • Hard to audit.

Model Queue (Deterministik)

Event ev;
ev.type = EventType::BUTTON;
xQueueSendFromISR(eventQueue, &ev, ...);

Keunggulan:

  • Event typed.
  • Extensible.
  • Tidak berbagi state.
  • Ordering bisa ditelusuri.

Message-based system adalah fondasi sistem scalable.


8. Mental Model yang Harus Dikunci di Tahap Foundation

Sekarang kita kunci mental model ini secara eksplisit.


1️⃣ Concurrency adalah default

Anda tidak mengaktifkan concurrency. Anda hidup di dalamnya.


2️⃣ ISR bukan bagian dari loop

ISR adalah interrupt context. Bukan function biasa.


3️⃣ Shared mutable state adalah risiko

Semakin banyak global mutable, semakin tinggi probabilitas race.


4️⃣ Queue lebih aman daripada global

Message passing lebih deterministik daripada shared memory.


5️⃣ Mutex bukan solusi utama

Mutex bisa:

  • Menyembunyikan desain buruk.
  • Menyebabkan deadlock.
  • Mengurangi determinism.

Gunakan hanya jika domain resource memang shared.


6️⃣ Scheduling memengaruhi determinism

Delay kecil + network spike = jitter kontrol.

Ini akan sangat relevan ketika kita masuk ke memory discipline dan communication state machine.


🔒 Konsolidasi Final Artikel 2

Setelah Artikel 2, engineer harus:

  • Mengerti bahwa concurrency bukan tambahan.
  • Mampu membuat task tambahan.
  • Mampu membuat queue.
  • Mampu menghindari race sederhana.
  • Mampu menjelaskan kenapa volatile tidak cukup.
  • Mengerti kenapa mutex bisa berbahaya.
  • Memahami ownership actuator.

Artikel ini bukan tentang API FreeRTOS.

Artikel ini tentang:

Model berpikir sistem concurrent.

Tanpa model ini:

  • Artikel Memory akan terasa teoretis.
  • Artikel OOP akan terasa kaku.
  • Artikel Layering akan terasa dipaksakan.
  • Artikel Reliability akan terasa paranoid.

Dengan model ini:

Semua artikel berikutnya menjadi logis.


Artikel berikutnya:

📘 FreeRTOS Execution Model (ESP32 Context)

Kita akan masuk ke:

  • Stack per task secara mendalam
  • Heap global vs stack
  • Context switching cost
  • Core pinning
  • Watchdog interaction
  • Determinism impact

Karena concurrency tanpa memory awareness akan menjadi sumber kegagalan berikutnya.


Catatan Penyusunan Artikel ini disusun sebagai materi edukasi dan referensi umum berdasarkan berbagai sumber pustaka, praktik lapangan, serta bantuan alat penulisan. Pembaca disarankan untuk melakukan verifikasi lanjutan dan penyesuaian sesuai dengan kondisi serta kebutuhan masing-masing sistem.