Объём ресурсов, потребляемый контейнером, учитывается на уровне cgroup, к которой он относится.

Контейнер состоит из одного или более процессов, а процесс — из одного или более тредов.

Абстракцию можно углубить, разбивая тред на «зелёные потоки», в зависимости от рантайма это могут быть горутины (Golang), корутины (Kotlin/Python), виртуальные треды (Java) и другие.

Но о «зелёных» потоках Linux не знает, для него единицей планирования является тред (в терминах ядра — task_struct). Именно он планируется, выполняется и вытесняется другими тредами для выполнения на CPU.


С другой стороны у каждого CPU в системе есть своя runqueue — очередь тредов, готовых к исполнению (Runnable).

Планировщик на основе приоритетов, квот и доли использования CPU в рамках периода, решает какой тред из runqueue будет исполняться следующим. Таким образом, недостаточно быть в состоянии Runnable, тред должен оказаться первым среди остальных тредов.

Для понимания насколько эффективно тред справляется с конкуренцией, полезно отслеживать время его пребывания в runqueue.

Эксперимент

Запустим синтетическую CPU-bound нагрузку и проанализируем её с разных сторон.

$ while :; do :; done &
[1] 2676086
$ pidstat -p 2676086 -wu 1 10
[...]
Average:      UID       PID    %usr %system  %guest   %wait    %CPU   CPU  cswch/s   nvcswch/s  Command
Average:        0   2676086   99.90    0.00    0.00    0.00   99.90   -    0.00      2.40       bash

Ядро Linux охотно отдает все доступные ресурсы CPU под наш тред:

  • минимальное количество принудительных переключений контекста (nvcswch/s);
  • потребление CPU на уровне 100% в %usr (нулевой cswch/s).

$ python3 psn -p 2676086 -d 15 -a -G kstack # https://github.com/tanelpoder/0xtools
 samples | avg_threads | comm   | state            | kstack
------------------------------------------------------------
     300 |        1.00 | (bash) | Running (ON CPU) | -

Тред без переключений находится в Running.


$ runqlat-bpfcc -p 2676086 15 1 # https://github.com/iovisor/bcc 
usecs         : count   distribution
    0 -> 1    : 0     |                            |
    2 -> 3    : 0     |                            |
    4 -> 7    : 1     |*                           |
    8 -> 15   : 2     |**                          |
   16 -> 31   : 33    |****************************|
   32 -> 63   : 8     |*****                       |
   64 -> 127  : 2     |*                           |

Время нахождения в runqueue составляет от 16 до 64 микросекунд, что говорит об отсутствии каких-либо затруднений в планировании.


Теперь изменим условия и запустим ту же нагрузку, но с параметром CPUQuota=50% (CPU Limits на языке Kubernetes):

$ systemd-run --pty --quiet --collect --unit test.service --property CPUQuota=50% /bin/bash -c "while :; do :; done"

$ pidstat -p 2676086 -wu 1 10
[...]
Average:      UID       PID    %usr %system  %guest   %wait    %CPU   CPU  cswch/s   nvcswch/s  Command
Average:        0   2676086   50.00    0.00   0.00    50.00    50.00   -    0.00      10.90       bash

Теперь ядро ограничивает использование CPU:

  • %CPU на уровне 50%;
  • растет %wait (CPU Wait Time);
  • ядро чаще приостанавливает тред — nvcswch/s = 10.90.

$ runqlat-bpfcc -p 2676272 15 1
Tracing run queue latency... Hit Ctrl-C to end.

     usecs               : count     distribution
         0 -> 1          : 0        |                                        |
         2 -> 3          : 0        |                                        |
         4 -> 7          : 0        |                                        |
         8 -> 15         : 2        |                                        |
        16 -> 31         : 12       |***                                     |
        32 -> 63         : 0        |                                        |
        64 -> 127        : 0        |                                        |
       128 -> 255        : 0        |                                        |
       256 -> 511        : 0        |                                        |
       512 -> 1023       : 0        |                                        |
      1024 -> 2047       : 0        |                                        |
      2048 -> 4095       : 0        |                                        |
      4096 -> 8191       : 0        |                                        |
      8192 -> 16383      : 0        |                                        |
     16384 -> 32767      : 0        |                                        |
     32768 -> 65535      : 150      |****************************************|

Хвосты в промежутке 32768 - 65535 намекают на наличие троттлинга.


$ python3 psn -p 2676272 -d 15 -a -G kstack
[...]
 samples | avg_threads | comm   | state            | kstack
---------------------------------------------------------------------------------------------------------------
     150 |        0.50 | (bash) | Running (ON CPU) | -
     150 |        0.50 | (bash) | Running (ON CPU) | irqentry_exit_to_user_mode()->exit_to_user_mode_prepare()

Подтверждаем - в половине случаев тред исполняется (Running), а в другой половине находится в Runnable, ожидая планирования на процессор.

Running включает в себя состояние Runnable, отдельно готовность к исполнению в Linux не отслеживается.


Выводы

Хоть эксперимент максимально синтетический, но есть несколько интересных моментов:

  • троттлинг на уровне процесса(треда) наблюдается как CPU Wait Time;
  • тред “замораживается”, переходя в Runnable;
  • в такие моменты хвостовые задержки пребывания в runqueue становятся настолько большими, что говорить о производительности уже не приходится.

Последний пункт является весомым аргументом пересмотреть использование квот в пользу cpu.shares (cpu requests) — особенно когда важна скорость.