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.