Немного о сокетах, redis и битых яйцах
Работать в пятницу после обеда первого апреля не хочется — вдруг ещё техника выкинет какую-нибудь шутку. Потому решил о чем-либо написать.
Не так давно на просторах хабра в одной статье огульно охаяли сразу Unix-сокеты, mysql, php и redis. Говорить обо всём в одной статье не будем, остановимся на сокетах и немного на redis.
Итак вопрос: что быстрее Unix- или TCP-сокеты?
Вопрос который не стоит и выеденного яйца, однако, постоянно муссируемый и писать не стал бы если б не опрос в той самой статье, согласно которому едва-ли не половина респондентов считает, что лучше/надёжнее/стабильнее использовать TCP-сокеты.
Тем, кто и так выбирает AF_UNIX, можно дальше не читать.
Начнём с краткой выжимки из теории.
Сокет — один из интерфейсов межпроцессного взаимодействия, позволяющий разрабатывать клиент-серверные системы для локального или сетевого использования. Так как мы рассматриваем в сравнении (с одной стороны) Unix-сокеты, то в дальнейшем будем говорить об IPC в пределах одной машины.
В отличии от именованных каналов, при использовании сокетов прослеживается отличие между клиентом и сервером. Механизм сокетов позволяет создавать сервер к которому подключается множество клиентов.
Как реализуется взаимодействие со стороны сервера:
— системный вызов socket создаёт сокет, но этот сокет не может использоваться совместно с другими процессами;
— сокет именуется. Для локальных сокетов домена AF_UNIX(AF_LOCAL) адрес будет задан именем файла. Сетевые сокеты AF_INET именуются в соответствии с их ip/портом;
— системный вызов listen (int socket, int backlog) формирует очередь входящих подключений. Второй параметр backlog определяет длину этой очереди;
— эти подключения сервер принимет с помощью вызова accept, который создаёт новый сокет, отличающийся от именованного сокета. Этот новый сокет применяется только для взаимодействия с данным конкретным клиентом.
С точки зрения клиента подключение происходит несколько проще:
— вызывается socket;
— и connect используя в качестве адреса именованный сокет сервера.
Остановимся внимательнее на вызове int socket (int domain, int type, int protocol) второй параметр которого определяет тип обмена данными используемого с этим сокетом. В нашем сравнении мы будем рассматривать его возможное значение SOCK_STREAM, являющееся надежным, упорядоченным двунаправленным потоком байтов. То есть в рассмотрении участвуют сокеты вида
sockfd = socket (AF_UNIX, SOCK_STREAM, 0);
и
sockfd = socket (AF_INET, SOCK_STREAM, 0);
Структура сокета в домене AF_UNIX проста
struct sockaddr_un {
unsigned char sun_len; /* sockaddr len including null */
sa_family_t sun_family; /* AF_UNIX */
char sun_path[104]; /* path name (gag) */
};
В домене AF_INET несколько сложнее:
struct sockaddr_in {
uint8_t sin_len;
sa_family_t sin_family;
in_port_t sin_port;
struct in_addr sin_addr;
char sin_zero[8];
};
и на её заполнение мы понесём дополнительные расходы. В часности это могут быть расходы на — ресолвинг (gethostbyname) или выяснение того с какой стороны разбивать яйца (htons).
Также сокеты в домене AF_INET, не смотря на обращение к localhost, «не знают» того, что они работают на локальной системе. Тем самым они не прилагают никаких усилий, чтобы обойти механизмы сетевого стека для увеличения производительности. Таким образом мы «оплачиваем» усилия на переключения контекста, ACK, TCP управление потоком, маршрутизацию, разбиение больших пакетов и т.п. То есть это «полноценная TCP работа» несмотря на то что мы работаем на локальном интерфейсе.
В свою очередь сокеты AF_UNIX «осознают», что они работают внутри одной системы. Они избегают усилий на установку ip-заголовков, роутинг, расчёт контрольных сумм и т.д. Кроме того, раз в домене AF_UNIX используется файловая система в качестве адресного пространства мы получаем бонус в виде возможности использования прав доступа к файлам и управления доступа к ним. Тем самым мы можем без существенных усилий ограничивать процессам доступ к сокетам и опять же не несём затрат на этапы обсепечения безопасности.
Проверим теорию на практике.
Мне лень писать серверную часть, потому воспользуюсь тем же redis-server. Его функционал отлично для этого подходит и заодно проверим справедливы ли были обвинения в его адрес. Клиентские части набросаем свои. Будем выполнять простейшую команду INCR со сложностью O (1).
Создание сокетов намеренно помещаем внутри циклов.
TCP-клиент:
#include
#include
#include
#include
#include
#include
#include
#include
int main(int argc, char *argv[]) {
int sockfd, portno, n;
struct sockaddr_in serv_addr;
struct hostent *server;
char buffer[256];
if (argc < 4) {
fprintf(stderr,"usage %s hostname port count_req\n", argv[0]);
exit(0);
}
portno = atoi(argv[2]);
int i=0;
int ci = atoi(argv[3]);
for(i; i < ci; i++)
{
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("ERROR opening socket");
exit(1);
}
server = gethostbyname(argv[1]);
if (server == NULL) {
fprintf(stderr,"ERROR, no such host\n");
exit(0);
}
bzero((char *) &serv_addr, sizeof(serv_addr));
serv_addr.sin_family = AF_INET;
bcopy((char *)server->h_addr, (char *)&serv_addr.sin_addr.s_addr, server->h_length);
serv_addr.sin_port = htons(portno);
if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("ERROR connecting");
exit(1);
}
n = write(sockfd, buffer, strlen(buffer));
if (n < 0) {
perror("ERROR writing to socket");
exit(1);
}
bzero(buffer,256);
n = read(sockfd, buffer, 255);
if (n < 0) {
perror("ERROR reading from socket");
exit(1);
}
printf("%s\n",buffer);
close(sockfd);
}
return 0;
}
UNIX-клиент:
#include
#include
#include
#include
#include
#include
int main(int argc, char *argv[]) {
int sockfd, portno, n;
struct sockaddr_un serv_addr;
struct hostent *server;
char buffer[256];
if (argc < 1) {
fprintf(stderr,"usage %s count_req\n", argv[0]);
exit(0);
}
int i=0;
int ci = atoi(argv[1]);
for(i; i < ci; i++)
{
sockfd = socket(AF_UNIX, SOCK_STREAM, 0);
if (sockfd < 0) {
perror("ERROR opening socket");
exit(1);
}
bzero((char *) &serv_addr, sizeof(serv_addr));
serv_addr.sun_family = AF_UNIX;
strcpy(serv_addr.sun_path, "/tmp/redis.sock");
if (connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr)) < 0) {
perror("ERROR connecting");
exit(1);
}
char str[] = "*2\r\n$4\r\nincr\r\n$3\r\nfoo\r\n";
int len = sizeof(str);
bzero(buffer, len);
memcpy ( buffer, str, len );
n = write(sockfd, buffer, strlen(buffer));
if (n < 0) {
perror("ERROR writing to socket");
exit(1);
}
bzero(buffer,256);
n = read(sockfd, buffer, 255);
if (n < 0) {
perror("ERROR reading from socket");
exit(1);
}
printf("%s\n",buffer);
close(sockfd);
}
return 0;
}
Тестируем с одним клиентом:
# redis-cli set foo 0 ; time ./redistcp 127.0.0.1 6379 1000000 > /dev/null ; redis-cli get foo
OK
2.108u 21.991s 1:13.75 32.6% 9+158k 0+0io 0pf+0w
"1000000"
# redis-cli set foo 0 ; time ./redisunix 1000000 > /dev/null ; redis-cli get foo
OK
0.688u 9.806s 0:36.90 28.4% 4+151k 0+0io 0pf+0w
"1000000"
И теперь для двадцати паралелльных клиентов отправляющих 500000 запросов каждый.
для TCP: 6:12.86
# redis-cli info Commandstats
cmdstat_set:calls=1,usec=5,usec_per_call=5.00
cmdstat_incr:calls=10000000,usec=24684314,usec_per_call=2.47
для UNIX: 4:11.23
# redis-cli info Commandstats
cmdstat_set:calls=1,usec=8,usec_per_call=8.00
cmdstat_incr:calls=10000000,usec=22258069,usec_per_call=2.23
Тем самым, в целом, аргументами в пользу TCP-сокетов может служить лишь мобильность применения и возможность простого масштабирования. Но если же вам требуется работа в пределах одной машины, то выбор безусловно в пользу UNIX-сокетов. Поэтому выбор между TCP- и UNIX-сокетами — это, в первую очередь, выбор между переносимостью и производительностью.
На сим предлагаю любить Unix-сокеты, а вопросы тупоконечностей оставить жителям Лилипутии и Блефуску.