Multitasking in NASM Win16 Assembly
This is a nice example of Cooperative Multitasking. Each task has to decide if and when it yields. However based on the fact that the individual tasks are placed in a single program, this could sooner be called Cooperative Multithreading.

In the putstring procedure you erroneously wrote jmp putstring where you clearly wanted to write jmp .continue. That's an honest mistake.
What you do keep neglecting though is the DisplayPage parameter that BIOS expects in the BH register.

If you've programmed in MASM before, it can be hard to adopt the shorter way to load an address via the mov instruction. mov si, task_a does the same as your lea si, [task_a] but it is 1 byte shorter. You used this many times (11x).


    cmp     cx, 3                 ; change this to add more tasks
    jl      .continue
    ret                           ; no free task, return immediately
.continue:



This ressembles a beginner's code, yet I'm sure you are not a beginner!
The spawn_new_task procedure has a nice ret near its end, so why not jump there based on the opposite condition?

    cmp     cx, 3                 ; change this to add more tasks
    jnl     .ret                  ; no free task, return immediately

    ...

.ret:
    ret


Same thing happens here:


    cmp     byte [bx], 1          ; is this stack active
    je      .found_one
    jmp     .loop
.found_one:



Why not write (no need for the label this time):

      cmp     byte [bx], 1          ; is this stack active
      jne     .loop


Because your main task will always be active, there's no reason to consider it in the search for a free task slot. This saves one iteration forever.
Also there's no need to test the CX counter up front. Better have the test near the bottom and avoid the extraneous unconditional jump back. So changing from a Do While loop into a Loop While loop.
Starting from a byte that is known to be 0, inc byte [bx] will produce shorter code than mov byte [bx], 1.

spawn_new_task:                   ; find a free task, start pointers off at -1
    xor     cx, cx
    mov     bx, stack_status
.loop:
    inc     bx
    inc     cx
    cmp     [bx], ch              ; CH=0
    je      .done                 ; if equal, tasks is free
    cmp     cx, 2                 ; change this to add more 'spawnable' tasks
    jb      .loop
    ret                           ; no free task, return immediately
.done:
    inc     byte [bx]             ; task was free, reserve it


What's the comment "start pointers off at -1" about? Your code nowhere has any -1.

The bulk of this program contains empty space. You can easily move the free space for the additional stacks outside of the program file.



The following rewrite (FASM) reveals a code size reduction of 9% (171 → 156) and a file size reduction of 72% (735 → 208).

org 100h

main:
    mov     si, task_a
    call    spawn_new_task
    mov     si, task_b
    call    spawn_new_task

.loop_forever:
    mov     si, main_str
    call    putstring
    call    yield
    jmp     .loop_forever

task_a:
    mov     si, main_a
    call    putstring
    call    yield
    jmp     task_a

task_b:
    mov     si, main_b
    call    putstring
    call    yield
    jmp     task_b

spawn_new_task:                   ; find a free task slot
    xor     cx, cx
    mov     bx, stack_status
.loop:
    inc     bx
    inc     cx
    cmp     [bx], ch              ; CH=0
    je      .done                 ; if equal, task slot is free
    cmp     cx, 2                 ; change this to add more 'spawnable' tasks
    jb      .loop
    ret                           ; no free task, return immediately
.done:
    inc     byte [bx]             ; task was free, reserve it
    mov     bx, stack_pointers
    mov     [bx], sp              ; save main's stack pointer
    add     bx, cx                ; switch to free stack
    add     bx, cx                ; free stack is list of words (not bytes) and add is cheaper than mul
    mov     sp, [bx]              ; change to free stack
    push    si                    ; push stuff
    pusha
    pushf
    mov     [bx], sp              ; save new stack's new pointer
    mov     sp, [stack_pointers]  ; change back to main
    ret

yield:
    ; ret already saved by call
    pusha
    pushf
    movzx   cx, byte [current_task]
    ; save current stack pointer
    mov     bx, stack_pointers
    add     bx, cx
    add     bx, cx
    mov     [bx], sp
    ; loop through stack status looking for a 1
    mov     bx, stack_status
    add     bx, cx                ; make sure we start looking at next task
.loop:
    inc     bx
    inc     cx
    cmp     cx, 3                 ; increment this for more tasks
    jb      .continue
    xor     cx, cx                ; clear cx
    mov     bx, stack_status      ; reset bx back to start
.continue:
    cmp     byte [bx], 1          ; is this stack active
    jne     .loop
    mov     [current_task], cl
    ; switch to new stack
    mov     bx, stack_pointers
    add     bx, cx
    add     bx, cx
    mov     sp, [bx]
    popf
    popa
    ret

    ; takes an address to write to in si
    ; writes to address until a null term is encountered
    ; returns nothing
putstring:
    mov     bh, 0
    mov     ah, 0Eh
    jmp     .first
.next:
    int     10h                   ; call interrupt
.first:
    lodsb                         ; put char to write in al
    test    al, al                ; simulate and
    jnz     .next                 ; was al 0?
    ret

    main_str: db "I am task main", 13, 10, 0
    main_a: db "I am task a", 13, 10, 0
    main_b: db "I am task b", 13, 10, 0

    current_task: db 0

    stack_status:
    status_1: db 1                ; this is for Main
    status_2: db 0
    status_3: db 0

    stack_pointers:
    dw 0                          ; this is for Main
    dw stacks + 256
    dw stacks + 512

    stacks: