scheduler.c

Basic task scheduling library
git clone git://git.finwo.net/lib/scheduler.c
Log | Files | Refs | README | LICENSE

README.md (10697B)


      1 # scheduler — Minimal Event-Loop Scheduler in C
      2 
      3 A lightweight, single-file event-loop scheduler for Unix-like systems. Uses `select(2)` for I/O multiplexing with a linked list of tasks invoked on every tick.
      4 
      5 ```c
      6 #include "src/scheduler.h"
      7 #include <stdio.h>
      8 #include <stdlib.h>
      9 
     10 static int greet(int64_t ts, pt_task_t *task) {
     11     (void)ts; (void)task;
     12     printf("Hello, world!\n");
     13     return SCHED_DONE;
     14 }
     15 
     16 int main(void) {
     17     sched_create(greet, NULL);
     18     sched_main();
     19     return 0;
     20 }
     21 ```
     22 
     23 Compile and run:
     24 ```sh
     25 gcc -o greet examples/example_basic.c src/scheduler.c && ./greet
     26 # Hello, world!
     27 ```
     28 
     29 ---
     30 
     31 ## Features
     32 
     33 - **Minimal** — ~120 lines of C, single header + single source file
     34 - **No dependencies** — only `<stdint.h>` and `<sys/select.h>`
     35 - **I/O multiplexing** — built-in `select(2)` integration via `sched_has_data()`
     36 - **Semaphores** — cooperative synchronization via `sched_sem_*()` primitives
     37 - **Cross-project friendly** — zero-configuration include via `config.mk`
     38 - **Extensible** — task `udata` lets you attach arbitrary state
     39 
     40 ---
     41 
     42 ## Installation
     43 
     44 ### With dep (recommended)
     45 
     46 ```sh
     47 dep add finwo/scheduler
     48 ```
     49 
     50 ### As a git submodule
     51 
     52 ```sh
     53 git submodule add https://github.com/finwo/scheduler.c.git scheduler
     54 ```
     55 
     56 Then in your `Makefile`, include `scheduler/config.mk`:
     57 ```make
     58 include scheduler/config.mk
     59 ```
     60 
     61 And add the include path:
     62 ```make
     63 CFLAGS += -I$(shell pwd)/scheduler
     64 ```
     65 
     66 ### Manual
     67 
     68 Copy `src/scheduler.h` and `src/scheduler.c` into your project.
     69 
     70 ---
     71 
     72 ## API Reference
     73 
     74 ### Status Codes
     75 
     76 | Macro | Value | Meaning |
     77 |-------|-------|---------|
     78 | `SCHED_RUNNING` | 0 | Task wants to keep running; scheduler will call it again next tick |
     79 | `SCHED_DONE` | 1 | Task has finished; scheduler removes it from the list |
     80 | `SCHED_ERROR` | 2 | Task encountered an error; scheduler removes it |
     81 
     82 ### Task Callback
     83 
     84 ```c
     85 typedef int (*pt_task_fn)(int64_t timestamp, pt_task_t *task);
     86 ```
     87 
     88 Every task is a function of this type. It receives:
     89 
     90 - `timestamp` — current time in **milliseconds since epoch**
     91 - `task` — the task handle; use `task->udata` to access your attached data
     92 
     93 Return one of `SCHED_RUNNING`, `SCHED_DONE`, or `SCHED_ERROR`.
     94 
     95 ### Task Handle
     96 
     97 ```c
     98 typedef struct pt_task {
     99     struct pt_task *next;
    100     pt_task_fn      func;
    101     void           *udata;
    102     char            is_active;
    103     int             maxfd;
    104 } pt_task_t;
    105 ```
    106 
    107 The struct is opaque — create tasks via `sched_create()` and manage them through the public API.
    108 
    109 ### `sched_create`
    110 
    111 ```c
    112 pt_task_t *sched_create(pt_task_fn fn, void *udata);
    113 ```
    114 
    115 Register a new task. The task is prepended to the internal linked list, so it runs **before** any existing tasks on every tick (LIFO order).
    116 
    117 - `fn` — your task callback; must not be `NULL`
    118 - `udata` — arbitrary pointer passed to every callback invocation
    119 - **Returns** — the new `pt_task_t*`, or `NULL` if `fn` is `NULL`
    120 
    121 > [!TIP]
    122 > Storing the returned `pt_task_t*` lets you remove the task later from any context — including from a different task or signal handler.
    123 
    124 ### `sched_remove`
    125 
    126 ```c
    127 int sched_remove(pt_task_t *task);
    128 ```
    129 
    130 Synchronously remove a task from the scheduler and free its memory.
    131 
    132 - `task` — handle returned by `sched_create`; `NULL` is a no-op
    133 - **Returns** — `0` on success, `1` if `task` was not found
    134 
    135 > [!WARNING]
    136 > Removing a task from within its own callback is safe — the scheduler saves the `next` pointer before invoking the callback.
    137 
    138 ### `sched_main`
    139 
    140 ```c
    141 int sched_main(void);
    142 ```
    143 
    144 Start the main event loop. Blocks until all tasks have been removed (each returned `SCHED_DONE` or `SCHED_ERROR`).
    145 
    146 - **Returns** — `0` on normal exit (all tasks completed), or immediately `0` if the task list is empty on entry
    147 
    148 ### `sched_has_data`
    149 
    150 ```c
    151 int sched_has_data(int *in_fds);
    152 ```
    153 
    154 Check which file descriptors are ready for I/O. Call this from within a task callback. The array format is `[count, fd1, fd2, ..., fdN]`, where negative file descriptors are silently skipped.
    155 
    156 **Phase 1 — register interest:** before `select()` fires, pass your FDs to register read interest.
    157 
    158 ```c
    159 int fds[3] = { 2, sock_fd, pipe_fd };
    160 int ready = sched_has_data(fds);  // registers sock_fd and pipe_fd
    161 ```
    162 
    163 **Phase 2 — check result:** after `select()` returns, call again to get the first ready FD.
    164 
    165 ```c
    166 int ready = sched_has_data(fds);  // returns first ready FD, or -1 if none
    167 ```
    168 
    169 - **Returns** — the first ready FD from the array, or `-1` if none are ready
    170 
    171 ### Semaphores
    172 
    173 Semaphores provide a lightweight synchronization primitive for coordinating tasks. The `value` field can represent signal counts, available bytes, or any resource count you choose.
    174 
    175 ```c
    176 #include "src/sched-sem.h"
    177 
    178 struct sched_sem {
    179     int value;
    180 };
    181 ```
    182 
    183 > [!NOTE]
    184 > Semaphores are allocated by the user on the stack or heap. The scheduler does not manage their lifetime.
    185 
    186 ### `sched_sem_init`
    187 
    188 ```c
    189 void sched_sem_init(struct sched_sem *sem, int amount);
    190 ```
    191 
    192 Set the semaphore to an initial value.
    193 
    194 ```c
    195 struct sched_sem mysem;
    196 sched_sem_init(&mysem, 5);  // Start with 5 available
    197 ```
    198 
    199 ### `sched_sem_signal`
    200 
    201 ```c
    202 void sched_sem_signal(struct sched_sem *sem, int amount);
    203 ```
    204 
    205 Increment the semaphore by `amount`. Use to produce resources or signal events.
    206 
    207 ```c
    208 sched_sem_signal(&mysem, 1);  // Add 1 to the count
    209 ```
    210 
    211 ### `sched_sem_consume`
    212 
    213 ```c
    214 void sched_sem_consume(struct sched_sem *sem, int amount);
    215 ```
    216 
    217 Decrement the semaphore by `amount`. Use to consume resources.
    218 
    219 ```c
    220 sched_sem_consume(&mysem, 1);  // Take 1 from the count
    221 ```
    222 
    223 ### `sched_sem_wait`
    224 
    225 ```c
    226 #define sched_sem_wait(sem) ...
    227 ```
    228 
    229 A macro that blocks the current task if the semaphore is `<= 0`. If `value > 0`, the macro falls through and the task continues. If `value <= 0`, it returns `SCHED_RUNNING` to defer to the next tick.
    230 
    231 ```c
    232 static int worker(int64_t ts, pt_task_t *task) {
    233     MyCtx *ctx = task->udata;
    234 
    235     sched_sem_wait(&ctx->sem);  // Wait for availability
    236     // If we get here, sem.value > 0
    237 
    238     sched_sem_consume(&ctx->sem, 1);  // Consume one unit
    239     // ... do work ...
    240 
    241     return SCHED_RUNNING;
    242 }
    243 ```
    244 
    245 ---
    246 
    247 ## Usage Examples
    248 
    249 Each example is a complete, compilable program.
    250 
    251 | Example | Demonstrates |
    252 |---------|-------------|
    253 | [example_basic.c](examples/example_basic.c) | Minimal one-shot task |
    254 | [example_periodic.c](examples/example_periodic.c) | Timer-based periodic task |
    255 | [example_io.c](examples/example_io.c) | I/O monitoring with `sched_has_data()` |
    256 | [example_removal.c](examples/example_removal.c) | Parent removes child task via returned `pt_task_t*` |
    257 | [example_multi.c](examples/example_multi.c) | Multiple coordinated tasks sharing state |
    258 | [example_sem.c](examples/example_sem.c) | Semaphore-based producer/consumer coordination |
    259 
    260 See [examples/README.md](examples/README.md) for build instructions.
    261 
    262 ---
    263 
    264 ## Architecture
    265 
    266 ### Event Loop
    267 
    268 `sched_main()` runs an infinite `for(;;)` loop:
    269 
    270 1. Scan `g_want_fds` to find the highest file descriptor
    271 2. Call `select()` with a **100 ms timeout** on that fd set
    272 3. Record the `select()` result in `g_select_result`; clear `g_want_fds`
    273 4. Call `gettimeofday()` and compute a millisecond timestamp
    274 5. Iterate the task list; invoke each task callback with the timestamp
    275 6. If a callback returns anything other than `SCHED_RUNNING`, remove the task
    276 7. Exit when the task list is empty
    277 
    278 ### Task List
    279 
    280 Tasks are stored in a singly-linked list. New tasks are **prepended** to the front (LIFO), meaning the most recently registered task runs first on each tick. This is a deliberate trade-off: O(1) insertion at the cost of insertion-order unpredictability.
    281 
    282 ### I/O Model
    283 
    284 The scheduler uses `select(2)` rather than `poll(2)` or `epoll(7)`. This keeps the library dependency-free and portable across all Unix systems, but carries the standard `select(2)` limitations (see [Limitations](#limitations)).
    285 
    286 ---
    287 
    288 ## Memory Management
    289 
    290 | Who allocates | Who frees | Notes |
    291 |---|---|---|
    292 | `sched_create()` → `udata` pointer | **Caller** | The scheduler never touches your `udata`. You must `free()` it yourself, typically in a cleanup task or after `sched_main()` returns. |
    293 | `sched_create()` → `pt_task_t` struct | **`sched_remove()`** | Freed automatically when the task returns `SCHED_DONE` or `SCHED_ERROR`, or when explicitly removed. |
    294 | `sched_sem` struct | **Caller** | Semaphores are allocated by the user. The scheduler does not manage their lifetime. |
    295 
    296 **Pattern for owned `udata`:**
    297 
    298 ```c
    299 static int cleanup_task(int64_t ts, pt_task_t *task) {
    300     MyCtx *ctx = task->udata;
    301     free(ctx->buf);
    302     free(ctx);
    303     return SCHED_DONE;
    304 }
    305 ```
    306 
    307 ---
    308 
    309 ## Signal Handling
    310 
    311 `sed_main()` calls `select(2)`, which is **not async-signal-safe** by itself. A few considerations:
    312 
    313 ### `select()` interruption
    314 
    315 If a signal arrives while `select()` is blocked, it returns `-1` with `errno == EINTR`. The scheduler currently **ignores** this error — it loops and retries. This means signals cause a ~100 ms delay in worst case (the timeout), which is usually fine.
    316 
    317 ### Calling `sched_remove()` from a signal handler
    318 
    319 This is **unsafe** — `sched_remove()` calls `free()` and modifies linked-list pointers, which are not async-signal-safe operations. If you need to stop tasks from signal handlers, use a volatile flag:
    320 
    321 ```c
    322 static volatile sig_atomic_t keep_running = 1;
    323 
    324 static int worker(int64_t ts, pt_task_t *task) {
    325     if (!keep_running) return SCHED_DONE;
    326     // ... do work
    327     return SCHED_RUNNING;
    328 }
    329 ```
    330 
    331 Then set `keep_running = 0` from the signal handler (this is safe because `sig_atomic_t` guarantees atomic read/write).
    332 
    333 ### `SA_RESTART`
    334 
    335 If you install signal handlers with `sigaction()`, consider `SA_RESTART`. This restarts `select(2)` automatically rather than returning `-1/EINTR`, which avoids the spurious loop iteration. The scheduler tolerates both modes.
    336 
    337 ---
    338 
    339 ## Limitations
    340 
    341 - **Not thread-safe.** All state lives in global/static variables. Use from a single thread, or wrap all calls in a mutex.
    342 - **`select(2)` constraint.** File descriptors are limited to `[0, FD_SETSIZE)` (typically 1024). For higher fd counts, `poll(2)` or `epoll(7)` would be needed.
    343 - **100 ms tick resolution.** The `select()` timeout is hardcoded to 100 ms. Tasks cannot run more frequently than this.
    344 - **LIFO ordering.** New tasks are prepended, not appended. Execution order on each tick is newest-first.
    345 - **No priorities.** All tasks are equal; there is no scheduling priority or weighting.
    346 - **No recurring tasks built-in.** Tasks must explicitly return `SCHED_RUNNING` to be called again.
    347 
    348 ---
    349 
    350 ## License
    351 
    352 See [LICENSE.md](LICENSE.md). ISC-style license — free to use, copy, modify, and distribute with attribution.