Konrad Kowalski (rootsher)Principal Platform & Reliability Architect110010011010001101100010000001101011001010100010

CPU Contention: kiedy proces chce CPU, ale go nie dostaje

data
kategoria
Capacity & Performance
także w
Computer Science
czytanie
3 min / 691 słów

CPU throttling ma jednoznaczną przyczynę:

text
workload wykorzystał swój CPU bandwidth limit

CPU contention jest inne.

Tutaj proces może nie mieć żadnego limitu, a mimo to nie dostawać tyle CPU, ile potrzebuje.

Powód jest prosty:

text
więcej tasków chce się wykonywać,
niż CPU jest w stanie wykonać jednocześnie

To właśnie CPU contention.


Najprostszy przypadek

Mamy jeden logiczny CPU:

text
CPU 0

i jeden CPU-bound task:

text
A

Task cały czas ma pracę.

Scheduler może więc praktycznie cały czas wykonywać:

text
CPU 0 -> A

Mamy:

text
demand   = 1 CPU
capacity = 1 CPU

Nie ma contention.

Dodajmy drugi identyczny task:

text
A
B

Oba chciałyby wykonywać się przez cały czas.

Ich łączny demand wynosi:

text
2 CPU

ale dostępna capacity nadal wynosi:

text
1 CPU

W jednej chwili CPU może wykonywać tylko jeden z nich:

text
CPU 0 -> A

B -> runnable, czeka

Po chwili scheduler może przełączyć:

text
CPU 0 -> B

A -> runnable, czeka

I tak dalej:

text
A -> B -> A -> B -> A -> B

To jest podstawowa forma CPU contention.


Contention zaczyna się przy demand > capacity

Najprostszy model:

text
CPU demand <= CPU capacity
    |
    v
brak konieczności czekania na CPU


CPU demand > CPU capacity
    |
    v
część runnable tasków musi czekać

Na maszynie z czterema logical CPUs:

text
capacity = 4 CPU

Jeżeli mamy cztery stale runnable single-threaded taski:

text
A
B
C
D

wszystkie mogą wykonywać się równolegle:

text
CPU 0 -> A
CPU 1 -> B
CPU 2 -> C
CPU 3 -> D

Dodajmy:

text
E

Teraz mamy:

text
5 runnable tasks
4 CPU

Przynajmniej jeden task musi w każdej chwili czekać.

Nie dlatego, że jest throttlowany.

Nie dlatego, że śpi.

Jest gotowy do wykonania.

Po prostu nie ma dla niego CPU.


Runnable task może niczego nie wykonywać

To najważniejsza rzecz w contention.

Task w stanie runnable mówi schedulerowi:

text
jestem gotowy,
daj mi CPU

Ale runnable nie oznacza:

text
aktualnie wykonuję instrukcje

Przykład:

text
time -------------------------------------->

task A:
RUNNING | RUNNING | RUNNING | RUNNING

task B:
WAIT    | WAIT    | RUNNING | WAIT

W momentach WAIT task B nadal może być runnable.

Nie czeka na:

text
dysk
sieć
timer
lock aplikacyjny

Czeka wyłącznie na scheduler.

To właśnie czas tracony przez CPU contention.


Runqueue

Runnable taski muszą być gdzieś reprezentowane przez scheduler.

W uproszczeniu można myśleć o runqueue:

text
CPU 0

running:
    A

runnable:
    B
    C
    D

Im więcej runnable pracy przypada na dostępną capacity, tym więcej czasu taski mogą spędzać na oczekiwaniu.

Nie chodzi przy tym wyłącznie o liczbę procesów.

Jeden proces może posiadać:

text
32 runnable threads

i sam wygenerować duży CPU demand.

Z punktu widzenia schedulera interesują nas taski gotowe do wykonania.


CPU usage nie pokazuje całego demand

Załóżmy aplikację, która przy braku ograniczeń mogłaby wykorzystać:

text
2 CPU

Node jest jednak przeciążony i scheduler jest w stanie dać jej średnio:

text
1 CPU

Monitoring pokaże mniej więcej:

text
CPU usage = 1 CPU

Ale to nie znaczy, że aplikacja potrzebowała tylko jednego CPU.

Realna sytuacja mogła być:

text
CPU demand = 2 CPU
CPU received = 1 CPU
CPU waiting = 1 CPU worth of work

Oczywiście „waiting = 1 CPU” jest tutaj modelem mentalnym, nie bezpośrednim licznikiem kernela.

Najważniejsze jest rozróżnienie:

text
usage = CPU time, który workload dostał

demand = CPU time, który workload chciał dostać

Jeżeli scheduler nie uruchomił taska, niewykonana praca nie pojawi się jako CPU usage.


Przykład z node Kubernetes

Załóżmy node:

text
8 CPU

Uruchomione workloady generują łącznie:

text
4 CPU demand

Mamy dużo wolnej capacity:

text
demand   = 4
capacity = 8

Nie ma globalnego contention.

Teraz przychodzi peak:

text
Workload A wants 3 CPU
Workload B wants 3 CPU
Workload C wants 4 CPU
Workload D wants 2 CPU

Łącznie:

text
demand = 12 CPU

na:

text
capacity = 8 CPU

Brakuje:

text
4 CPU

Nie oznacza to, że Linux nagle „tworzy” dodatkowe CPU.

Oznacza:

text
część runnable pracy będzie czekać

Scheduler będzie rozdzielał osiem dostępnych CPU pomiędzy konkurujące taski zgodnie ze swoimi regułami i wagami cgroups.


Contention nie wymaga limitu

To bardzo ważne po poprzednim artykule.

Załóżmy:

yaml
resources:
  requests:
    cpu: "1"

Brak:

yaml
limits:
  cpu:

Czyli workload nie posiada CPU bandwidth limitu.

W cgroup v2:

text
cpu.max = max ...

Nie może więc zostać throttlowany z powodu wyczerpania własnej quota.

Ale nadal może czekać na CPU.

Jeżeli node ma:

text
8 CPU

a runnable workloady chcą łącznie:

text
16 CPU

to fizycznie nie da się wykonać całej pracy jednocześnie.

Brak limitu nie oznacza:

text
zawsze dostanę tyle CPU, ile chcę

Oznacza tylko:

text
nie mam własnego twardego bandwidth ceiling

Throttling i contention wyglądają podobnie z punktu widzenia aplikacji

W obu przypadkach task może mieć pracę, ale nie wykonywać instrukcji.

Throttling

text
task runnable
     |
     v
CPU może być dostępne
     |
     v
quota wyczerpana
     |
     v
task nie może się wykonywać

Contention

text
task runnable
     |
     v
quota jest dostępna / brak limitu
     |
     v
CPU wykonuje inne taski
     |
     v
task czeka

Efekt aplikacyjny może być podobny:

text
większe latency
mniejszy throughput

Ale przyczyna jest zupełnie inna.


Więcej threadów nie tworzy więcej CPU

Załóżmy aplikację na node z:

text
4 CPU

Aplikacja tworzy:

text
4 worker threads

Jeżeli nic innego nie konkuruje, wszystkie mogą wykonywać się równolegle:

text
T1 -> CPU 0
T2 -> CPU 1
T3 -> CPU 2
T4 -> CPU 3

Dodanie kolejnych:

text
T5
T6
T7
T8

nie podwaja fizycznej capacity.

Teraz osiem threadów konkuruje o cztery CPU.

W każdej chwili:

text
4 -> running
4 -> runnable i czekają

oczywiście w uproszczeniu, bo scheduler ciągle przełącza taski.

Większa concurrency może więc zwiększyć:

text
runnable pressure

bez zwiększenia dostępnej mocy obliczeniowej.


Scheduler latency

Załóżmy task, który staje się runnable w:

text
t = 0 ms

ale scheduler uruchamia go dopiero:

text
t = 8 ms

Mamy:

text
0 ms                8 ms
|--------------------|
      runnable
       waiting

Dopiero później:

text
8 ms       10 ms
|-----------|
  running

Task potrzebował:

text
2 ms CPU time

ale wykonanie operacji trwało co najmniej:

text
10 ms wall-clock

z czego:

text
8 ms

to było oczekiwanie na możliwość wykonania.

Ten czas nazywamy w praktyce scheduler latency albo runqueue latency.

I jest on jednym z najbardziej bezpośrednich skutków CPU contention.


Dlaczego contention szczególnie boli latency-sensitive workloady

Batch workload może dostać mniej CPU i po prostu skończyć pracę później.

Dla request-response service sytuacja jest gorsza.

Załóżmy request HTTP wymagający:

text
3 ms CPU time

Bez contention:

text
~3 ms execution

Przy przeciążonym node:

text
5 ms waiting
3 ms running
4 ms waiting

otrzymujemy:

text
12 ms

mimo że aplikacja nadal zużyła tylko:

text
3 ms CPU time

CPU work się nie zmienił.

Zmienił się czas, kiedy proces mógł go wykonać.

Dlatego contention bardzo szybko zaczyna być widoczne w latency.


Model mentalny

CPU contention można sprowadzić do jednego warunku:

text
runnable CPU demand
>
available CPU capacity

Wtedy:

text
task ma pracę
      |
      v
runnable
      |
      v
CPU zajęte przez konkurencję
      |
      v
task czeka
      |
      v
scheduler w końcu wybiera task
      |
      v
running

Najważniejsze rozróżnienie względem poprzedniego artykułu:

text
THROTTLING:
nie możesz używać CPU,
bo wyczerpałeś quota

CONTENTION:
możesz używać CPU,
ale inni też chcą je teraz dostać

W obu przypadkach aplikacja może przestać wykonywać pracę.

Tylko powód jest inny.