Пользователь
0,0
рейтинг
28 июля 2013 в 18:23

Разработка → Настоящий многопоточный веб-сервер на ассемблере под Linux из песочницы

Добрый день, хабр!
Сегодня я вам расскажу как написать свой настоящий веб-сервер на асме.

Сразу скажу, что мы не будем использовать дополнительные библиотеки типа libc. А будем пользоваться тем, что предоставляет нам ядро.

Уже только ленивый не писал подобных статей, — сервер на perl, node.js, по-моему даже были попытки на php.

Вот только на ассемблере еще не было, — значит нужно заполнить пробелы.

Немного истории


Как-то раз мне нужно было хранить мелкие файлы (меньше 1Kb), их было ооочень много, я боялся за ext3, и решил я хранить все эти файлы в одном большом, а отдавать посредством веб-сервера, задавая в get параметре смещение и длину самого файла в hex виде.

Времени было прилично, решил я немного извратиться и написать это на асме.

Итак, приступим


Писать будем на FASM, т.к. нравится он мне, да и к Intel-синтаксису я привык.

Итак, стандартная процедура создания elf:

format elf executable 3
entry	_start
segment readable writeable  executable


Далее некоторые данные для заголовков:

HTTP200 db    "HTTP/1.1 200 OK",	0xD,0xA ;
CTYPE	db    "Content-Type: application/octet-stream", 0xD,0xA ; 
CNAME	  db    'Content-Disposition: attachment; filename="BIGTABLE"',0xD,0xA,0xD,0xA ;
SERVER 	db    'Server: Kylie',0xD,0xA ;
KeepClose db 'Connection: close',0xD,0xA,0xD,0xA  

; и переменные для sendfile
off_set         dd 0x00
n_bytes     dd 0x00


А также путь к тому самому большому файлу в котором хранятся все картинки:

FILE1   db    "/home/andrew/FILE.FBF",0


Определим несколько констант для удобства:

IPPROTO_TCP	equ	0x06
SOCK_STREAM	equ	0x01
PF_INET 	equ	0x02
AF_INET 	equ	0x02


Подключим самописную функцию перевода из str в hex

include 'str2hex.asm'


Принцип работы данной функции прост:

Забиваем в google.com.ua «Таблица ASCI», — распечатываем, и смотрим на нее…
Замечаем, что значения в ASCII от 0 — 9 соответствуют значениям от 30h до 39h

А значения от A до F в диапазоне от 41h до 46h

Входной параметр для макроса — адрес буфера в esi (по этому адресу — строка, которую надо перевести из str в hex)
Макрос просто проверяет код ASCII символа и если он больше 39h, — то работаем с A — F, если меньше или равно ему то с 0 — 9

Вот его полный код:

; esi,- адрес на строковый id
Возвращаемые значения:
; eax  - результат работы
Macro STR2HEX4
{
local  str2hex,bin2hex, out_buff, func, result, nohex
; // Локальный макрос для определения (строка больше 9 (т.е. A..F) или меньше)
cld ;// Флаг направления (в сторону увеличения)
mov edi,out_buff ; 
jmp func
;// Та самая проверка
str2hex:
cmp al,39h
jle nohex
sub al,07h
nohex:
sub al,30h
ret

out_buff dd 0x00

func:
; // Будем считать 4 раза (32 бит)
mov ecx,4

bin2hex:

	lodsb ;// Загрузим первое значение
	call str2hex ;// Конвертируем его ASCII код в значение
	shl  al,4 ; // Сдвинем на 4 (это будут старшие 4 бита)
	mov bl,al	; // Сохраним его в bl
	lodsb ; // Загрузим следующий
	call str2hex ; // Конвертируем (Это будут младшие 4 бита)
	xor al,bl	; // Объединим старшие и младшие биты
; // Все готово, теперь в AL у нас результат от первой пары символов
	stosb		; // Сохраним его в edi на всякий пожарный

sub ecx,1		; // Уменьшим счетчик на 1

jecxz result		; Продолжаем пока ecx != 0
jmp bin2hex		;

result:
;// В результате все аккуратно сложим в регистр eax
	xor eax,eax
	cld
	mov esi,out_buff
	lodsb
	shl eax,8
	lodsb
	shl eax,8
	lodsb
	shl eax,8
	lodsb

	; На выходе - значение в eax
}


P.S. Функция лишена обработчиков ошибок, поэтому надеюсь вы будете правильно задавать размер-смещение (обратите внимание, параметры регистрозависимы. Т.е. A != a, B =! b и т.д.)

Также максимальный размер и максимальное смещение = 32 бит.

Разобрались, поехали дальше:
Теперь наконец пришло время создать сокет

; // Заполняем структуру для сокета
    push  IPPROTO_TCP	     ; IPPROTO_TCP (=6)
    push  SOCK_STREAM	     ; SOCK_STREAM (=1)
    push  PF_INET	     ; PF_INET (=2)

;socketcall
    mov eax, 102	; // Функция 102 (работа с сокетами)
    mov ebx, 1	; // 1 говорить что нужно создать сокет
    mov ecx, esp	; // Указатель на нашу структуру в стеке
    int 0x80		 
    mov edi,eax ; // Сохраним значение в edi, т.к. он нам еще пригодится 
    cmp eax, -1
    je near errn	 ; // Проверим на ошибки


Сокет создан, биндим его на адрес 0.0.0.0 (в простонароде — INADDR_ANY) и порт 8080 (т.к. на 80м у меня работает lighttpd, и если поменять на 80й то в eax вернется 0 и произойдет ошибка -EADDRINUSE говорящая о том что порт уже занят)

; binding
    push 16	        ; socklen_t addrlen
    push ecx		; const struct sockaddr *my_addr
    push edi		; int sockfd
    
    mov eax, 102	; socketcall() syscall
    mov ebx, 2		  ; bind() = int call 2
    mov ecx, esp	; // Указатель
    int 0x80		
    
    cmp eax, 0
    jne near errn ;// Проверим на ошибки (если порт занят например...)


Кстати про использование INADDR_ANY. Если вы хотите использовать localhost, или любой другой адрес вы должны написать его «наоборот». Т.е.
localhost = 127.0.0.1 = 0x0100007F
habrahabr.ru = 212.24.43.44 = 2C2B18D4

Тоже самое каcается и номеров порта:

8080 = 901Fh
25 = 1900h

Конечно вам ничего не мешает указать ip как-то так:

localhost db 127,0,0,1
habrahabr.ru db 212,24,43,44

и т.д.

Ну и наконец начинаем прослушивать сам сокет на принятие новых соединений:

    push 1	  ;// int backlog
    push edi  ;// int sockfd
    pop esi
    push edi
    mov eax, 102	; // syscall
    mov ebx, 4	;// указывает что необходимо прослушивать сокет (listen)
    mov ecx, esp	; // указатель на нашу структуру
    int 0x80		


Теперь важный момент. Т.к. мы будем работать с процессами, то родительский процесс будет ожидать код возврата от дочернего после fork, и при завершении дочернего процесса родитель так и будет «думать» что он еще есть. Таким образом из дочерних процессов появляются зомби. Если мы скажем родителю что будем игнорировать эти сигналы то никого никто ждать не будет, и зомби появляться также не будут:

	mov eax,48
	mov ebx,17
	mov ecx,1    ; SIG_IGN
	int 0x80


Создаем структуру для accept и начинаем принимать соединения:

    push 0x00		
    push 0x00		 ; struct sockaddr *addr
    push edi		; int sockfd
sock_accept:
    mov eax, 102	; socketcall() syscall
    mov ebx, 5		  ; accept() = int call 5
    mov ecx, esp	
    int 0x80		
; // Проверка на ошибки:
    cmp eax, -1
    je near errn
    mov edi, eax	; Теперь в edi будет хранится 
    mov [c_accept],eax


Если ошибок никаких не возникло и мы оказались в этой части кода, значит подключился новый клиент

Создадим процесс для обработки:

mov eax,2 ; // Системный вызов sys_fork()
int 0x80
cmp eax,0
jl exit   ; if error


Теперь выясним кем мы тут являемся, форком или родительским процессом:

test eax,eax
jnz fork   ; Переходим на отработку запроса от клиента (дочерний процесс)
    ; edi - accept descriptor
    ; // Закрываем коннекшн в родителе и возвращаемся к принятию других клиентов
    mov eax, 6		; close() syscall
    mov ebx, edi	; The socket descriptor
    int 0x80		; Call the kernel
    jmp sock_accept 
fork:
;// Дальше - код обработки запроса


Все! «Голова» нашего сервера готова.

Дальше идет код исключительно для дочернего процесса

Отправим клиенту статус 200 OK
    mov eax, 4		  ; write() syscall
    mov ebx, edi	  ; sockfd
    mov ecx, HTTP200	  ; Send 200 Ok
    mov edx, 17 	  ; 17 characters in length
    int 0x80		  ;


Также тип контента. «application/octet-stream» — самый универсальный в данном случае

    mov eax, 4		  ; write() syscall
    mov ebx, edi	  ; sockfd
    mov ecx, CTYPE	  ; Content-type - 'application/octet-stream'
    mov edx, 40 	  ; 40 characters in length
    int 0x80		  ; Call the kernel


Название сервера:
  mov eax, 4		 ; write() syscall
    mov ebx, edi	 ; sockfd
    mov ecx, SERVER   ; our string to send
    mov edx, 15 	 ; 15 characters in length
    int 0x80		 ; Call the kernel


Так как наш сервер пока не поддерживает Keep-Alive то признаемся в этом:
    mov eax, 4		  ; write() syscall
    mov ebx, edi	  ; sockfd
    mov ecx, KeepClose	  ; Connection: Close
    mov edx, 21 	  ; 21 characters in length
    int 0x80		  ; Call the kernel


Обратите внимание, необходимо отправить в конце два раза 0xD 0xA (мы это сделали вместе с отправкой Connection: Close) и можно считать что с заголовками покончено

Ну а теперь собственно узнаем какой файл хочет скачать клиент. Для этого поместим в буфер запрос GET со сдвигом в 5 байтов влево, тем самым обрезая ненужную информацию(‘GET /’), оставляя только чистый ID размером в 16 байт.

Ах да, я все об id, id … А что он из себя представляет? Я решил все сделать просто, указав в GET 32-битное значение для смещения в файле, и сразу за ним 32 битное значение равное размеру файла.

Т.е. если запрос URL выглядит таким образом:

127.0.0.1/00003F480000FFFF

То смещение в файле равно 00003F48 а размер запрошенных данных — 0000FFFF

mov esi,buffer    ; // Поместим адрес откуда читать наш id (для STR2HEX)

push edi		; Сохраним edi т.к. макрос его очищает
STR2HEX4	; Макрос принимает буфер по адресу esi
pop edi 		; возвратим edi 

mov [off_set],eax ; // функция возвратила значение в eax, сохраним ее в переменной


Теперь нам нужно открыть большой файл, где начало файла будет с заданным смещением:

Сейчас просто откроем его (дескриптор будет сохранен в eax):

; Open BIG file
        mov eax,5
        mov ebx,FILE1
        mov ecx, 2
        int 0x80   


Теперь для полного удовлетворения пришло время использовать функцию sendfile.
Как пишут в мануалах:

Because this copying is done within the kernel, sendfile() is more efficient than the combination of read(2) and write(2), which would require transferring data to and from user space.


; Send [n_bytes] from BIGTABLE starting at [off_set]
send_file:

       mov ecx,eax         ; file descriptor from previous function
       mov eax,187
       mov ebx,edi         ; socket
       mov edx,off_set     ; pointer 
       mov esi,[n_bytes]   ;
       int 0x80


Как вы поняли дескриптор из eax мы скопировали в ecx для функции sendfile, не сохраняя его в промежуточных регистрах\памяти.

success

Вот здесь в свое время я долго не спал по ночам, потому что не мог понять почему же после отправки всех байт файл не скачивается полностью, а за секунду до полного скачивания браузер пишет «Сетевая ошибка» и его не сохраняет. В sendfile ошибок не возникало, пришлось научится пользоваться chrome developer tools.

Оказывается что после отправки самого файла, браузер шлет заголовок, который сервер должен принять. Не важно какие там данные, — его все равно можно отослать в /dev/null но очень важно что бы сервер его прочел. Иначе браузер посчитает что с файлом что-то не то. Зачем именно так сделано — на 100% мне неизвестно. Мне кажется что это связано с возможным отсутствием Content-Length в заголовках, когда файл принять надо, а сколько данных браузер заведомо не знает. Буду признателен если кто-то откроет тайну )))

Итак, принимаем браузерный хедер:
Читаем из адреса в edi, в адрес buffer

; Read the header 
   mov eax,3
   mov ebx,edi
   mov ecx,buffer
   mov edx,1024
   int 0x80     


Если заголовки не слишком большие то 1024 байта вполне хватит
(Если на этом домене не используете длинных кук и т.д.)

Закрытие файла и завершение:
    mov eax, 6            ; close() syscall
    mov ebx, edi        ; The socket descriptor
    int 0x80            ; Call the kernel
; end to pcntl_fork ()
    mov eax,1
    xor ebx,ebx
    int 0x80  


Вообще файл можно держать открытым какое-то время в родителе, и использовать его остальными форками, для экономии времени. Но это не совсем правильный вариант.

И самое главное!
Никаких внешних библиотек!

root@server:/home/andrew# ldd server
not a dynamic executable


Ссылка для скачивания (можно проверить работает\нет, протестить бенчмарком ab например)))
http://ubuntuone.com/3yNexPG0yewlGnjNd6219W

P.S. В коде упущено множество проверок на ошибки, также в некоторых кусках кода не подчищается стек, наличие некоторых переменных подобрано вручную (за отсутствием нормальной документации), и в общем код не претендует на звание самого «чистого».

Сервер хорошо работает на многоядерных системах (проверено на Core I7 2600). Он обгоняет lighttpd у меня на сервере по статике почти в 4 раза, хотя я думаю что мой lighttpd просто не настроен на многоядерность.

Что быстро можно добавить:
Ну например cgi для любого языка (php, perl, python) и т.д. Также возможно убрать считывание из файла, и написать работу с файловой системой а также добавить виртуальные хосты. А вообще все ограничено только вашей фантазией.
@Hocok_B_KapMaHe
карма
30,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Реклама

Самое читаемое Разработка

Комментарии (91)

  • +87
    Мсье знает толк.
  • +2
    Да уж! Месье знает толк… :)
  • +3
    Что то подобное видел на wasm еще в 4 году или около того.
  • +9
    В свое время в небезызвестной книге С.В.Зубкова восхитило это:

    Самый компактный вариант преобразования шестнадцатеричной цифры в ASCII-код соответствующего символа:

    cmp al,10
    sbb al,96h
    das
    • +4
      Компактный, но не самый быстрый. Эти команды, aas, das и прочие медленно работают… ведь только в скорости смысл при написании сервера на асм =)
      • +2
        Интересно, будет ли работать такое (для hex->bin):
        mov ah,al
        shr ah,4
        xor ah,33h
        sub al,ah
        

        Команд столько же, сколько у автора, зато без переходов.
        • +5
          Не стоит так же забывать про таблички и XLAT.
          hex db '0123456789ABCDEF'
      • +5
        Не так уж они медленно работают:

        cmp ~1 такт, sbb — задержка 2 такта, das — 4.5 такта — для SandyBridge users.atw.hu/instlatx64/GenuineIntel00206A7_SandyBridge_InstLatX86.txt с сайта instlatx64.atw.hu/

        Вот на P4 медленнее, das около 100 тактов (57 микроопераций), sbb 6-10 тактов. Да еще и у AMD 16-20 микроопераций на das, задержка 7-10 тактов.

        По микрооперациям для Sandy — das =3, sbb =2, cmp=1, все на p015 www.agner.org/optimize/instruction_tables.pdf — стр 120-121

        По конкретному коду разбивку по портам и общую задержку можно попытаться посмотреть в Intel IACA, software.intel.com/en-us/articles/intel-architecture-code-analyzer

        Другое дело что команды das в 64-битном режиме нет — 2F — Invalid Instruction in 64-Bit Mode ref.x86asm.net/coder64.html#x2F (вот для 32-битного ref.x86asm.net/coder32.html#x2F)
    • +1
      69h, а не 96h.
  • +4
    HTTP заголовок может содержать параметр Range, где задается смещение и длина.
    Если есть возможность задавать заголовки, то можно настроить обычный сервер на подобные манипуляции.
    А так вполне себе решение
    • 0
      не совсем понимаю как вы собираетесь заставить браузер использовать Range для загрузки скажем изображения, если известно смещение и размер, при использовании предложенного сервера мы просто прописываем урл в теге img.
      • +1
        Nginx может творить чудеса, из того же пути получить смещение и размер, проставить их в заголовки и спроксировать на себя же.
        Не проверял, но теоретически ничего не мешает это сделать.
        • 0
          Я так понял, что проблема на стороне браузера: если браузер отправил Range запрос, то он вряд ли станет считать, что ему пришел целый файл, и не станет обрабатывать полученный кусок.
          Если мы сами себе злобные буратины, то можно полученные данные через canvas отобразить. Но это ж костыль, лучше offs-len через URL передавать, как и предлагается.
          • 0
            Проблема в том что автору нужно прочитать хотябы обзор о HTTP протоколе.
          • 0
            Вообще-то не так. Если возвращается 200, то Range проигнорирован, и браузер всё съест правильно. На правильный Range-запрос возвращается 206.
            В реальных задачах Range, конечно, нужно учитывать.
  • +3
    Зачем именно так сделано — на 100% мне неизвестно. Мне кажется что это связано с возможным отсутствием Content-Length в заголовках, когда файл принять надо, а сколько данных браузер заведомо не знает. Буду признателен если кто-то откроет тайну )))
    Вы или указывайте Content-Length, либо Transfer-Encoding: chunked. Или попробуйте отвечать HTTP/1.0.
  • +2
    Потрясающе!

    единственное, что немного цепляло взгляд — сишные комменты после ассмовских. Причем, только вначале. Вызывают небольшой когнитивный диссонанс. Видя такие комменты подсознание отказывается воспринимать асм, хотя ты и видишь код и все понимаешь)
    • 0
      Вёзёт вам! Смотрите асм и всё понимаете. А я вот за 15 лет всё напрочь забыл :)
  • +3
    Потестил ab, первый раз ab -c 100 -n 1000 выдал 0.5 мс на запрос, каждый последующий запуск был медленнее, после 3-го запуска стал выдвать 23 мс. Нода и Эрланг с cowboy выдают примерно по 0.1 мс на запрос стабильно. Почему так?
    • +5
      Нода и Эрланг крутяться в одном физическом процессе, а тут сделанно через форк, который происходит в режиме ядра, да и вообще не самая дешевая операция. Да и вообще, тоже самое можно было бы написать на С, а компилятор сгенерировал бы аналогичный код. В данном случае написание на асемблере не дает никакого особого выгрыша по производительности.
      • –5
        Насколько я знаю сервера типа Apache взаимодействуют с libc которая в свою очередь является оберткой для системных вызовов, что (незначительно) снижает производительность. Да, в данном случае какого-либо выигрыша скорей всего не заметишь
        • +2
          Вы по этому решили написать сей «шедевр»?

          Если бы вы вместо fork-on-accept использовали однопозадачный паттерн с select или стартовали трёды или epool… То было бы на много быстрее. А на сишке сразу и быстрее написалось бы и быстрее работало бы.

          • +1
            Если будет стоять задача написать быстрый веб-сервер, — я буду использовать epoll.
            На данный момент у меня на это нет ни времени, ни желания т.к. приходится заниматься другими задачами.

            А если вам так не иметься — возьмите и напишите.

            Статья получилась сама собой, и код таков каков он есть.
      • 0
        f0rk,
        Не стоит сравнивать epoll/select с sys_fork.
        А на счет результатов, — попробуйте потестить все таки на многоядерной машине.
        • –1
          Тестил на 4-х ядерном i5-2430M
          • +1
            Дружище, огорчу тебя — проц 2-х ядерный.
            • 0
              Для ОС он логически 4х поточный. Если уже придираться к мелочам.
            • 0
              Ну, может быть… Я глянул cat /proc/cpuinfo, увидел 4 ядра и написал, могу не шарить :)
  • +1
    C 0W сравните, пожалуйста (http://0w.ru/httpd/).
  • +30
    Автор вы это… забегайте к нам в проект что ли habrahabr.ru/company/kolibrios/ ;-)
    • 0
      Свой веб-сервер в KolibriOS! Да. Это было бы прекрасно!
      • 0
        Someday, keysi. Someday.
  • –8
    Как лаба зачет. Но даже на курсач не тянет.

    же только ленивый не писал подобных статей, — сервер на perl, node.js, по-моему даже были попытки на php.

    Вот только на ассемблере еще не было, — значит нужно заполнить пробелы.

    Писали то писали, но это бло хоть на что-то похожее. Хотя бы вместо «HTTP/1.1 200 OK» версию 1.0 поставили бы. А то несоответствия, и вообще ужас.

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

    Вообще интел асм достаточно приятный язык. Когда-то я любил на нём писать конечные автоматы и стейт машины.
  • +34
    Тю. Я рассчитывал на high-performance хардкор, с параллельным использованием порта несколькими процессами (недавная фишка линуксов), а получил в результате тупой listen-n-fork, даже без epoll.

    Любой приличный веб-сервер уделает это чудо в разы.
    • 0
      Не подскажете, где можно почитать про «использование порта несколькими процессами»?
      • +4
        • 0
          Спасибо. Я правильно понимаю, что это позволяет открыть на прослушку скажем порт 80 несколькими процессами одновременно и расхватывать входящие соединения параллельно?
          • 0
            Верно. Сделано чтобы устранить узкое место в виде одного слушающего процесса.
            Под наш любимый nginx тоже патчики есть. Прирост весьма существенный.
            • 0
              И соединения теряются при обновлении конфигурации или бинарника. Пока реализацию в ядре не починят — штука малоюзабельна.

              Статью на LWN надо читать со слов:
              The other noteworthy point is that there is a defect in the current implementation of TCP SO_REUSEPORT.

              p.s. У nginx нет узкого места «в виде одного слушающего процесса».
              • 0
                Хмм… А тогда за счёт чего вышеупомянутый патч даёт прирост?
                • 0
                  Из сообщения не совсем ясно, но весьма вероятно, что сравнение идет против включенного accept_mutex-а.
                  • 0
                    Я по-быстрому потестил на дефолтном конфиге с разницей только во включенном или выключенном so_reuseport.
                    До 2-х тысяч одновременных соединений — быстрее без so_reuseport, свыше (проверял вплоть до 20-ти тысяч) — уже быстрее с so_reuseport, на ~10%.
                    • 0
                      Так не надо на дефолтном. Вы сравниваете nginx с включенным accept_mutex-ом (по умолчанию) против патча, который помимо всего прочего accept_mutex отключает. Последний является частой причиной заниженных цифр в искуственных бенчмарках.
                      • 0
                        accept_mutex on;
                        so_reuseport off;
                        Requests per second: 3342

                        accept_mutex off;
                        so_reuseport off;
                        Requests per second: 3307

                        accept_mutex off;
                        so_reuseport on;
                        Requests per second: 3874

                        ab -c 20000
                        среднее 4-х итераций
                        Intel Atom D525 :)

                        • 0
                          ab локально и в одном экземпляре? Оно вам показывает положение звезд на небе, ab использует select() и работает в разы медленнее nginx-а, во всех трех случаях последний простаивает большую часть времени.
                          • 0
                            перепроверил с weighttp, он использует эффективные методы (libev).

                            C включенным so_reuseport — 3723 req/s
                            Без патча — 2933 и 3117 req/s (accept_mutex off/on)

                            Таким образом по положению звёзд тоже можно ориентироваться. Хотя бы направление понять. :)
                            • 0
                              Добавьте ещё multi_accept on;.
                              • 0
                                То же самое.
                                Предлагаю обсудить
                                p.s. У nginx нет узкого места «в виде одного слушающего процесса».
                                • 0
                                  Тут и обсуждать нечего, каждый рабочий процесс слушает сокет. Иными словами процессов много и на многоядерной системе они могут исполняться параллельно, а с отключенным accept_mutex-ом accept'ить соединения одновременно. Значит узкое место имеет место быть в ядре, но только вместо его устранения был придуман способ требующий дополнительных усилий со стороны разработчиков, плюс лишающий nginx одного из ключевых преимуществ — обновление версии и конфигурации без потери соединений. Разработчик DragonFlyBSD осознал свою ошибку и ушел править ядро, а когда тем же самым займутся в линуксе — неизвестно.
                                  • 0
                                    Разве не будет EADDRINUSE, если ещё раз вызвать listen() для сокета на том же порту?
                                    • 0
                                      Не нужно делать несколько раз listen(). Мастер-процесс nginx-а, который читает конфигурацию, делает по одному listen() на каждый сокет, а затем уже форкается на указанное количество рабочих процессов.
                                • +1
                                  Ну или просто бенчмарк слишком искусственный, тестирует достаточно вырожденный случай, а в реальности ситуация не так однозначна.

                                  Как утверждает автор:
                                  In case #2, the proportion of connections accepted per thread tends
                                  to be uneven under high connection load (assuming simple event loop:
                                  while (1) { accept(); process() }, wakeup does not promote fairness
                                  among the sockets. We have seen the disproportion to be as high
                                  as 3:1 ratio between thread accepting most connections and the one
                                  accepting the fewest. With so_reusport the distribution is
                                  uniform.

                                  Иначе говоря, с включенной опцией ядро начинает раскладывать соединения по сокетам в равной пропорции, вместо того, чтобы рабочие процессы разбирали из общей копилки по мере необходимости. Хорошо ли это? Как сказать, в случае, когда все соединения эквивалентны (установлены одним клиентом, запрашивают один и тот же ресурс, по одному и тому же каналу), т.е. требуют одинаковых ресурсов на обработку — по идее особой разницы не должно быть, но равномерное распределение видимо улучшает баланс. Именно этот случай и реализует ваш бенчмарк.

                                  Но реальность она чаще всего отличается от этих идеальный условий. Представьте, что на сервер поступают разные по тяжести запросы, легкие, средние и тяжелые. Когда ядро раскладывает соединения, оно не знает, в каком из них будет легкий запрос, а в каком тяжелый, в итоге один воркер может получить много легких запросов и быстро с ними справиться, обслужит всю свою очередь соединений на своем дескрипторе, а затем будет простаивать, в то время как другой всё ещё пыхтит над несколькими тяжелыми запросами. И не только запросы отличаются, отличаются клиенты, один сможет быстро прислать запрос, другой будет слать медленно, пакет за пакетом, один сможет быстро забрать ответ, другой будет вытягивать его долго.
                                  • 0
                                    Когда ядро раскладывает соединения, оно не знает, в каком из них будет легкий запрос, а в каком тяжелый
                                    Собственно nginx тоже не знает, каким будет соединение, и какие запросы потом ещё придут.
                                    итоге один воркер может получить много легких запросов и быстро с ними справиться, обслужит всю свою очередь соединений на своем дескрипторе, а затем будет простаивать
                                    Простаивать точно не будет, так как запросы на сервер продолжают поступать (если это не бенчмарк).

                                    Так что я думаю, что на момент accept()-а соединения, что у ядра, что у nginx-а примерно одинаковые возможности по балансировке — по загрузке процессора.

                                    Если бы nginx поддерживал потоки, ситуация с равномерностью загрузки была бы красивее.
                                    • +1
                                      Собственно nginx тоже не знает, каким будет соединение, и какие запросы потом ещё придут.

                                      Да, но если вы выхватываете из общей очереди по мере того, как у вас освобождаются ресурсы, то у вас не возникает дисбаланса в распределении нагрузки, когда один воркер заткнулся на тяжелых запросах, а другой простаивает после обработки серии легких.

                                      Баланс по нагрузке и баланс по кол-ву соединений — не когерентные соотношения, потому что разные соединения не равны между собой.

                                      Простаивать точно не будет, так как запросы на сервер продолжают поступать (если это не бенчмарк).

                                      Типичный сервер не загружен на все 100% и определенное время рабочие процессы все равно простаивают. Будут расти задержки у тех запросов, которым не повезло попасть в очередь к воркеру с более тяжелыми запросами, будет ухудшаться QoS.

                                      Так что я думаю, что на момент accept()-а соединения, что у ядра, что у nginx-а примерно одинаковые возможности по балансировке — по загрузке процессора.

                                      В том то и дело, что когда вы раскидываете соединения грубо-говоря в round-robin'е по отдельным дескрипторам, а не раздаете их по мере освобождения рабочих процессов, вы отказываетесь от балансировки по загрузке процессора, а предполагаете, что все соединения эквивалентны.

                                      Если бы nginx поддерживал потоки, ситуация с равномерностью загрузки была бы красивее.
                                      В чем же разница между ядерными потоками и процессами в этом аспекте? =)
                                      • 0
                                        В легковесности — их можно создавать быстро и много.
                                        В качестве отправной точки для значения worker_processes документация предлагает использовать количество ядер.

                                        Вот есть у нас 16 ядер, и, соответственно 16 воркеров. И заняты он тем, что все жмут gzip-ом большие HTML-страницы — ну очень процессороёмкая операция. Приходит новое соединение с запросом на маленькую картинку. Оно принимается мастер-процессом (или откладывается в очередь) и ждёт, пока освободится воркер. А ведь ресурсов, чтобы его отдать прямо сейчас, практически не нужно.
                                        • 0
                                          В легковесности — их можно создавать быстро и много.
                                          В Windows системах оно может быть и так. В *nix их создание эквивалентно по стоимости.

                                          Оно принимается мастер-процессом
                                          Мастер-процесс не работает с пользовательскими соединениями вообще. Его задача исключительно читать конфигурацию и перезапускать рабочие процессы.

                                          Вот есть у нас 16 ядер, и, соответственно 16 воркеров. И заняты он тем, что все жмут gzip-ом большие HTML-страницы — ну очень процессороёмкая операция. Приходит новое соединение с запросом на маленькую картинку.… и ждёт, пока освободится воркер. А ведь ресурсов, чтобы его отдать прямо сейчас, практически не нужно.
                                          Как это связано с обсуждаемой темой про SO_REUSEPORT? =) Очевидно никак.

                                          Вы же понимаете разницу между асинхронным подходом, используемым в nginx, и многопоточным синхронным сервером? В последнем случае скедулинг осуществляет ОС, а не сам nginx. И проблема в обоих случаях одинакова — на что отдать процессорное время, в какой момент переключить задачу. Nginx сжав кусочек страницы, заполнив буфер, примет запрос на картинку.
                                          • 0
                                            Мастер-процесс nginx-а, который читает конфигурацию, делает по одному listen() на каждый сокет
                                            Мастер-процесс не работает с пользовательскими соединениями вообще

                                            Я уже немного запутался. Попробую подобрать более показательные тесты. Для gzip-ов в том числе.
                                            • 0
                                              Вы упустили из первой цитаты:
                                              , а затем уже форкается на указанное количество рабочих процессов.
                                              Именно рабочие процессы зовут accept(). Мастер этим не занимается.
                                              • +1
                                                Три часа ночи. Выглянул в окно, бомжи трощат мусорные баки. Больная фантазия выдала аналогию.

                                                Действующие лица:
                                                Бомжи — это воркеры (worker_processes).
                                                Мусорные баки — сокеты
                                                Жильцы с мусорными вёдрами — сессии (connection)
                                                Процесс переработки/сортировки мусора бомжами — полезная работа

                                                Сцена первая

                                                Один мусорный бак, четыре бомжа. Жильцы изредка выносят мусор. Бомжи не торопясь разгребают мусор, долго ожидая поступления новой порции.

                                                Сцена вторая

                                                Поток жильцов увеличивается, бомжи не покладая рук выгребают мусор

                                                Сцена третья

                                                Жильцов становится больше, появляется очередь к единственному мусорному баку. Бомжи нервничают, мешают друг другу, т.к. в один момент времени копаться в баке может только один.

                                                Сцена четвёртая

                                                Бомжи не справляются. Рассерженные жильцы начинают кидать мусор вокруг бака (backlog). Бомжи ругаются, обвиняя жильцов в чрезмерной генерации мусора (DDoS), и не выдержав, начинают кидать в них разные бумажки (syncookies).

                                                Обиженные жильцы вместе со своим мусором, приходят жаловаться в ЖЭК/управляющую компанию, грозясь высыпать мусор им на головы и сменить поставщика услуг.
                                                ЖЭК говорит, спокойно — вот есть решение (SO_REUSEPORT) и выделяют каждому бомжу свой персональный мусорный бак.

                                                Но, замечают, что есть один недостаток (defect in the current implementation) — если во время того, как жилец высыпает мусор, бачок вдруг взять и забрать, то часть мусора выпадет на землю, а что упало — то пропало (connection lost).
                                                • +1
                                                  в один момент времени копаться в баке может только один.

                                                  Бомжи ничего об этом не знают, они подходят все одновременно и одновременно запускают туда руки, но внутренняя конструкция бака оказывается такова, что одновременно только один бомж умудряется в нем что-то найти. Почему бы не усовершенствовать конструкцию бака?

                                                  ЖЭК говорит, спокойно — вот есть решение (SO_REUSEPORT) и выделяют каждому бомжу свой персональный мусорный бак.

                                                  Каждый жилец, выходящий из дома, подходит к следующему мусорному баку за тем, в который высыпал свой мусор предыдущий жилец. При этом каждый второй из них тащит тяжеленный мешок с мусором после гулянки, в то время, как остальные вышли выкинуть по кожуре от банана, а бака на этом участке два, и получается так, что в один попадают большие мусорные пакеты, а во второй только кожура. Бомжи между собой поделили мусорные баки, и каждый может копаться только в своем. В итоге один оказывается голоден, а другой жирует.

                                                  Но во всей этой аналогии есть одно большое упущение. Вы считаете, что копаться в мусорном баке — это основная работа бомжей и у них там действительно постоянно возникает contention. Но реальность такова, что на порядок большую часть времени бомжи занимаются тем, что разгребают то, что извлекли из баков, а сама процедура извлечения занимает менее 1% времени в работе бомжа. Причем в случае nginx-а, бомжей сравнительно мало, но каждый из них копается одновременно в сотнях и тысячах уже извлеченных мешков.
                                                  • 0
                                                    Отличный ответ, Спасибо!

                                                    К сожалению, у меня нет рабочего сервера, где было бы 20к подключений, обычно на порядок меньше. Поэтому нигде nginx не является узким местом — разгребает весьма эффективно. Синтетика (и комментарии с патчу) говорят, что выгода будет на очень большом объёме. На 2к у меня nginx и так работет быстрее, чем с патчем.

                                                    Буду благодарен, если вы предложите какую-нибудь методику тестирования, более приближенную к полевым условиям.
        • 0
          Спасибо, а я уже полез в архивы за статьёй.
  • +1
    Любой приличный веб-сервер уделает это чудо в разы.
    ИМХО, целью автора было совсем не скорость, т.к.
    Времени было прилично, решил я немного извратиться и написать это на асме.
    И кроме того это чудо тоже уделает любой приличный веб-сервер! По размеру кода, разумеется.
    • +14
      Есть две причины, почему в современном мире имеет смысл писать на ассемблере (есть ещё третья — под процессор нет компилятора, но это не наш случай):
      * Показ того, как оно на самом деле работает, показ принципов архитектуры
      * Оптимизация на скорость

      Оптимизация на размер для веб-сервера — нонсенс, не заслуживающий упоминания. Просто потому, что для того, чтобы эта программа стала нормальной, ей нужно: init-скрипт, поддержка правильной демонизации и остановки, control-файл и всё это завёрнутое в deb/rpm. На выходе будем иметь кратное увеличение размера, так что никого просто не будет волновать — 100кб оно там или 30.

      В остальном ни одна из целей выполнена не была — не было показано культурное ipc на ассемблере (что крайне любопытный вопрос) — вместо этого подложили скучный fork, не были показаны особенности архитектуры (epoll хотя бы, не говоря уже про новую семантику tcp-сокетов), под которую реализация. Про скорость уже сказано.

      Что остаётся?
      • +2
        Оптимизация на размер для веб-сервера — нонсенс, не заслуживающий упоминания.
        Если вы поработаете со встраиваемыми устройствами, то, скорее всего, измените свою точку зрения.

        К тому же, на многих из них, функциональность веб-сервера является далеко не основной задачей. Он изредка используется для контроля/управления устройством.
        • +3
          Если мы поработаем со встраивыеми устройствами, то поймем, что в MIPS'ах и ARM\ах нет никаких int 80h
          • 0
            Это был комментарий на «оптимизацию на размер», ни слова по ARM, MIPS или int 80h. (где вы их увидели?)
            И да, оказывается на x86 есть масса встраиваемых решений, вы не знали?
          • 0
            Не поленился посмотреть маныулы. Так вот ARM-овскойм асме есть инструкция SWI которая точно так же (через механизм прерываний) используется для Linux-овых системных вызовов. Пример использования. Так что ваш комментарий «мимо кассы».
            • +1
              Да чего вы на меня так накинулись, как будто я сказал, что линукс гавно. Я просто сказал, что в большинстве встраиваемых систем нету привычного int 80h, вот и все. И да, конечно есть похожие механизмы вызова системных функций.
              • 0
                Я просто сказал, что в большинстве встраиваемых систем нету привычного int 80h, вот и все.
                А где-то утверждалось обратное? Открытие Америки — надо же, на других аппаратных платформах, используется другой ассемблер, и там нет x86-ых инструкций! Вау! К чему вообще был этот комментарий? Почему он получил плюсик? Почему мой, абсолютно корректный, получил минус?
        • +4
          Если вы используете x86 в встраиваемом устройстве, вам уже насрать на сотни килобайт. Да. Если же вы хотите выпендрится старинными 8-битными однокристалками, то в посте не тот ассемблер не с тем ядром.
          • 0
            Ещё раз, комментарий был на
            Оптимизация на размер для веб-сервера — нонсенс, не заслуживающий упоминания.
            т.е. без привязки к конкретной платформе и применению. Веб-сервер может быть на погодной станции, где достаточно выдавать несколько чисел в браузер. Или на такой симпатичной x86 плате с 32МБ памяти.
      • +1
        не говоря уже про новую семантику tcp-сокетов

        Подскажите пожалуйста, а о чем идет речь? Или где об этом можно почитать подробнее?
  • +1
    Оказывается что после отправки самого файла, браузер шлет заголовок, который сервер должен принять. Не важно какие там данные, — его все равно можно отослать в /dev/null но очень важно что бы сервер его прочел. Иначе браузер посчитает что с файлом что-то не то. Зачем именно так сделано — на 100% мне неизвестно. Мне кажется что это связано с возможным отсутствием Content-Length в заголовках, когда файл принять надо, а сколько данных браузер заведомо не знает. Буду признателен если кто-то откроет тайну )))

    А что за заголовок, какой браузер? А вообще, как сказали выше — Content-Length отдавайте. Так браузер хотя бы будет уверен, что файл пришел полностью, а если не отдавать, то при разрыве связи в процессе передачи браузер может посчитать, что передача файла завершилась.
    • 0
      Хмм… Возможно, бразуер пытается использовать заголовки Range: и Content-Range: и брать файл по частям? Я вот сейчас с этим столкнулся: отдаю аудио-файл — и даже посылаю Content-Length, но проигрыватель в браузере глючит — хотя музыку играет, а файл полностью уходит с сервака. Видимо, у меня более сложный случай :)
  • +1
    Действительно если портировать этот веб-сервер на KolibriOS посмотреть бы результаты бенчмарков.
  • +9
    Сильно смущают всякие
    cmp al, 39h

    вместо
    cmp al, '9'

    и
    mov eax, 6            ; close() syscall
    mov ebx, edi        ; The socket descriptor
    int 0x80            ; Call the kernel

    вместо
    mov ebx, edi        ; The socket descriptor
    call close


    Или целью было написать еще и максимально запутанный код?

    Плюс, если мне память не изменяет, в FASM тоже есть invoke, и вместо всяких
    push A
    push B
    push C
    call X

    можно просто писать
    invoke X, A, B, C

    • 0
      Если смареть с точки зрения быстродействия, то call, jmp и всякие работы со стеком — это ОГРОмадный минус этому самому быстродействию. Если хочется скорости — не нужно никаких процедур, циклов, и тем более, работы со стеком. Нужно побороть в себе «эстета» и если цикл дублируется, скажем, раз 10, то можно его вообще развернуть и не делать цикл (loop тоже тормозная команда). Так же и, казалось бы, процедурами — да, код много раз повторяется, противоречит всем «правилам» относящимся к языкам высокого уровня (хотя тут еще ка посмотреть) но гораздо лучше копипастить хоть тыщу раз, чем call'ы и стек.
      • 0
        Я тут ниже писал, что код в статье не похож на написанный с учетом оптимизации скорости/размера. А если писался для изучения темы — тогда уже и подход к написанию ожидается другой.
  • 0
    call close — это будет линковка с glibc, а автор хотел:
    Сразу скажу, что мы не будем использовать дополнительные библиотеки типа libc. А будем пользоваться тем, что предоставляет нам ядро.
    Поэтому через system call.
    • +5
      Я имел ввиду вынести все эти syscall'ы (или взять готовый файл) вида:
      call:
          mov eax, 6            ; close() syscall
          int 0x80            ; Call the kernel
          ret
      ...

      чем использовать константы непосредственно в коде.
      • +3
        Вариант автора занимает 18 байт (два вызова по 9 байт), ваш — 22 ( два вызова по 7 байт плюс 8 на тело функции) и медленней.
        • +6
          Но мой выходит понятнее. У автора вроде не было гонки ни за каждым байтом, ни за скоростью. Судя по комментам в коде и обхяснению в статье — пример это был учебный. Тогда уж и писать понятный код :)

          Я вон когда в свое время развлекался подобным образом и делал растровый графический редактор или драйвер HDD с AES шифрованием на MASM, не гнался за максимально компактным кодом. Для этого есть категория demo-прог. Вообще в этом плане я поддерживаю коммент amarao.
        • +2
          Еще вот бегло глянул макросы fasm, и получается, что в качеcтве эквивалента 18 байтного кода достаточно написать:
          macro fclose sock
          {
              mov ebx, sock
              mov eax, 6
              int 0x80
          }
          

          Тогда вызов получается:
          fclose edi
          

          Круто же! Почему бы не использовать такие возможности.
          • 0
            Круто, но: во-первых далеко не всегда короткий код самый быстрый; во-вторых макросы обоснованы, когда хочется получить высокое (предсказуемое) качество выполнения программы, игнорируя размер и скорость работы, ибо если взять более серьезные макросы (а не обычные процедуры, которые в макросы запихивать тоже тот еще изврат), типа сравнения чего-то с чем-то, то на выходе получается говнокод похлеще индусского. В основном за счет добавления мусорных опкодов, которые даже никогда не исполняются.
  • 0
    Снимаю шляпу, понравилось.
  • +1
    Я правильно понимаю, что расширение .FBF — это f#$%ing big file?
  • +1
    Topface делал то же самое на node-js habrahabr.ru/post/184652/, и в отличии от этой статьи, там есть реальные бенчмарки в сравнении.
    Кроме того там в комментариях советуют elliptics от Yandex той же парадигмой.

Только зарегистрированные пользователи могут оставлять комментарии. Войдите, пожалуйста.