linux高性能服务器的搭建

IO模型

  1. 阻塞IO和非阻塞IO的区别就在于对非就绪事件的处理, 对于设置了O_NONBLOCK的文件描述符来说,若此时文件描述符非可读或者可写,针对其进行的系统调用会返回-1,设置errno,而对未设置的文件描述符的connect,recv,send会阻塞进程

  2. 管道pipe是环形缓冲区

  3. O_NONBLOCK对一般文件而言没有什么影响,所有一般文件都是可读或者可写的,它主要用来在fcntl时设置socket或者字符设备的属性,详情请见O_NONBLOCK on regular file

    字符设备,即流设备,如tty,pts,拥有自己的缓冲区,并且采用行缓冲,每个进程的STDIN_FILENO,STDOUT_FILENO软链接到当前终端中(/dev/stdin及其他也和这三个文件描述符软链接),所以使用splice等系统调用从标准输入读取中会读取换行符

  4. 同步或者异步I/O主要是指访问数据的机制(即实际I/O操作的完成方式),同步一般指主动请求并等待I/O操作完毕的方式,I/O操作未完成前,会导致应用进程挂起;而异步是指用户进程触发IO操作以后便开始做自己的事情,而当IO操作已经完成的时候会得到IO完成的通知(异步的特点就是通知),这可以使进程在数据读写时也不阻塞。阻塞或者非阻塞I/O主要是指I/O操作第一阶段的完成方式(进程访问的数据如果尚未就绪),即数据还未准备好的时候,应用进程的表现,如果这里进程挂起,则为阻塞I/O,否则为非阻塞I/O。说白了就是阻塞和非阻塞是针对于进程在访问数据的时候,根据IO操作的就绪状态来采取的不同方式,说白了是一种读取或者写入操作函数的实现方式,阻塞方式下读取或者写入函数将一直等待,
    而非阻塞方式下,读取或者写入函数会立即返回一个状态值。
    而在并发模式中,同步指的是程序严格按照代码顺序执行, 异步指的是程序的执行需要系统事件来驱动

关于splice函数的盲点

SPLICE_F_MORE对于写入端是socket的情况下有效,如果设置了 SPLICE_F_MORE 那么会追加 MSG_MORE flag;如果当前 pipe 中还有其他的 page,则追加 MSG_SENDPAGE_NOTLAST flag。

首先来看 MSG_MORE 的作用,熟悉 TCP 的应该已经知道了这个标志表示调用者有更多的数据要发送。在 TCP socket 中,该标志用于获取与 TCP_CORK 相同的效果,不同之处在于该标志可以在每次调用时设置。顾名思义 TCP_CORK 就是给 TCP 发送数据的时候加了一个木塞子。往这个 socket 写入的数据都会聚集起来。虽然堵上了塞子,但是数据总得发送,取决于:

  • 程序取消设置 TCP_CORK 这个选项

  • socket 聚集的数据大于一个 MSS 的大小

  • 自从堵上塞子写入第一个字节开始,已经经过 200ms

  • socket 被关闭了

    此外,当设置SPLCICE_NONBLOCK标志时,若读取端设置为非阻塞且没有准备好数据,则会触发EAGAIN错误

三种设备驱动

即为dev目录下的设备文件是如何实际硬件进行交互的,一般是通过驱动程序进行

进一步-VFS虚拟文件系统

Linux中的文件描述符与打开文件之间的关系

两个不同的文件描述符,若指向同一个打开文件句柄,将共享同一文件偏移量

详情

信号

常用信号
信号 含义 默认行为
SIGINT 键盘输入以中断进程(Ctrl + C) term(终止进程)
SIGQUIT 键盘输入使进程退出(Ctrl + ) core(结束进程并生成核心转储文件)
SIGSTOP 暂停进程(Ctrl + S), 该信号不得被捕获或者忽略 stop(暂停进程)
SIGCHLD 子进程状态发生变化(退出或者暂停) ign(忽略信号)
SIGPIPE socket连接对方的读端已经关闭或者管道的读端已经关闭,此时发送方使用send或者往管道的写端写数据,都会触发该信号 term
SIGHUP 控制终端被挂起(Ctrl + Z),可以使用kill -HUP来向进程发送该信号 term
SIGTERM kill命令默认发送信号 term
  1. 信号处理函数指针__sighandler_t
    • 系统定义两种处理函数
      • SIG_DFL (__sighandler_t) 0 采用信号默认处理方式 /整形->函数指针强制类型转换/
      • SIG_IGN (__sighandler_t) 1 忽略信号
  2. 自定义信号处理函数:sigaction系统调用
    参数:
    • sig,要捕获的信号
    • act,struct sigaction*
      • 结构体中的sa_handler是信号处理函数指针,用于指定函数处理函数
      • sa_mask,类型为信号集sigset_t,用于设定增加的信号掩码(在原进程基础上)
        • sa_mask指定了一个信号集的掩码,这些信号在信号处理程序执行期间应该被阻塞(即添加到调用信号处理程序的线程的信号掩码中)。此外,除非使用了SA_NODEFER标志,否则触发处理程序的信号也将被阻塞。
      • sa_flags, 整形数据,用于设置程序接收到信号时的行为
        • SA_RESTART, 含义是重新调用被该信号终止的系统调用
        • SA_NODEFER, 一般情况下,在接收信号并进入其信号处理函数时,该信号会被加入至线程的信号掩码中,防止接收同种信号,引发竞态条件,设置该选项可以使得同种信号也被线程接收
          成功返回0,失败返回-1并设置errno
  3. struct sigset 用于表示一组信号集,其中定义了一个长整型数组,每个元素的每个位都代表着一个信号, 这组信号集可以用来设置进程掩码,标志着某个信号将会被阻塞,手册上给出了掩码和阻塞的说明:
    • 一个信号可能会被阻塞,这意味着直到解除阻塞之前它将不会被传递。在生成和传递之间的时间内,信号被称为待处理的(pending)。
    • 进程中的每个线程都有一个独立的信号掩码,它指示线程当前阻止的信号集。线程可以使用pthread_sigmask(3)来操作其信号掩码。在传统的单thread线程应用程序中,可以使用sigprocmask(2)来操作信号掩码。
  4. sigprocmask函数也可以用sigset来指定新的进程掩码,通过how参数可以设置新掩码集和当前掩码集的交互方式
  5. 可以使用sigpending函数获取被挂起的信号集,当通过设置进程掩码导致新的信号被屏蔽时,新的信号会被操作系统设置为挂起的信号,若取消对信号的屏蔽,则可以立刻被进程接收到
  6. fork出的子线程会继承父线程的信号集合,但会拥有空的挂起信号集
  7. 正在阻塞的系统调用可以被信号处理函数中断(interrupt), 若在sigaction建立信号处理函数时设置了SA_RESTART标志,则某些系统调用可以在信号处理函数返回之后重启(restart), 有些则不论是否设置该标志都会失败,并设置errno为EINTR
    • SA_RESTART标志有效的系统调用包括: read,write,ioctl,open(如果能被阻塞,比如当打开FIFO时),以及常见的socket接口
    • SA_RESTART标志无效的系统调用包括: 通过setsockopt设置socket选项为超时SO_RECVTIMEO的输入输出socket接口, 以及IO复用select,poll,epoll_wait
    • sleep函数永远不会重启但会成功返回,并返回剩余的sleep秒数
  8. 当挂起进程的控制终端时,SIGHUP将被触发,对于没有控制终端的网络后台程序来说,通常利用SIGHUP信号来强制服务器重读配置文件
  9. linux环境下,内核通知应用程序**带外数据(大小1字节,通常被tcp接收端存储到特殊缓存中,称为带外缓存)**到来的方式有两种:
    • 使用IO复用函数监听事件集中的异常事件,select等系统调用在接收到带外数据时将返回,并向应用程序报告socket上的异常事件,此时应用程序可以调用FD_ISSET等函数来查看异常事件是否发生
    • 使用SIGURG信号,此时应用程序可设置信号处理函数来读取带外数据

      对于带外缓存来说,可以设置连接socket的SO_OOBINLINE选项来将带外数据存放在接收端的普通数据输入队列中,此时我们不能使用MSB_OOB来读取数据

进程间通信 IPC

  1. 如果文件事先已经存在,
    open(pathname, O_RDWR | O_CREAT,0666); 打开成功,返回一个大于0的fd
    open(pathname, O_RDWR | O_CREAT | O_EXCL,0666); 打开失败,返回-1

    O_EXCL表示的是:如果使用O_CREAT时文件存在,就返回错误信息,它可以测试文件是否存在。并且此时检查文件和打开文件是原子性操作,避免了进程临界区竞争

    在NFS(网络文件系统)上,O_EXCL可能不是原子性的,此时可以调用link系统调用创建hard link来查看文件是否存在(通过使用stat可以查看st_nlink的数量)

  2. IPC对象包括:信号量(semaphore),共享内存,消息队列, System V 和 POSIX对于三种进程间通信的接口不同,具体内容如下:

特性 System V IPC POSIX IPC
IPC 机制 管道、命名管道、消息队列、信号、信号量、共享内存、套接字、Unix 域套接字 管道、命名管道、消息队列、信号量、共享内存、套接字
共享内存接口调用 shmget(),shmat(),shmdt(),shmctl() shm_open(),mmap(),shm_unlink()
消息队列接口调用 msgget(),msgsnd(),msgrcv(),msgctl() mq_open(),mq_send(),mq_receive(),mq_unlink()
信号量接口调用 semget(),semop(),semctl() 命名信号量:sem_open(),sem_close(),sem_unlink(),sem_post(),sem_wait(),sem_trywait(),sem_timedwait(),sem_getvalue() 无名或基于内存的信号量:sem_init(),sem_post(),sem_wait(),sem_getvalue(),sem_destroy()
IPC 对象的标识 键和标识符 名称和文件描述符
监视 POSIX 消息队列 select(),poll(),epoll() NA
消息队列控制 msgctl() 提供函数(mq_getattr()和mq_setattr())
线程同步 互斥锁、条件变量、读写锁 多线程安全
消息队列通知 NA 提供少量通知特性(例如mq_notify())
状态/控制操作 需要系统调用(shmctl())、命令(ipcs, ipcrm) 可使用系统调用(例如fstat(), fchmod())检查和操作共享内存对象
共享内存段的大小 在创建时固定(通过shmget()) 可使用ftruncate()调整底层对象的大小,然后重新创建映射使用munmap()(或Linux特定的mremap())

进程的退出

  • 当exec失败时,应该使用_exit(或其同义词_Exit)来终止子进程,因为在这种情况下,子进程可能通过调用其atexit处理程序、调用其信号处理程序和/或刷新缓冲区来干扰父进程的外部数据(文件)。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    switch (fork()) {
    case 0:
    // we're the child
    execlp("some", "program", NULL);
    _exit(1); // <-- HERE
    case -1:
    // error, no fork done ...
    default:
    // we're the parent ...
    }
  • 当使用fork(),尤其是vfork()时,exit()和_exit()之间存在一些差异,这些差异变得很重要。

  • fork()会将父进程的虚拟地址空间复制一份给子进程,并在需要时写时复制(copy on write), 否则二者映射到内存同一位置,若某方对数据发生了改变,则父进程将复制一份数据给子进程,此时二者的函数调用栈在内存中的位置不同,vfork()则是二者共享一片内存区域,此时子进程若使用return语句,则会弹栈,破坏父进程函数调用栈,exit()则不会更改函数栈结构,而是将回收内存任务交给操作系统,接下来看一个例子

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    #include <stdio.h>
    #include <unistd.h>

    void stack1() {
    vfork();
    }

    void stack2() {
    _exit(0);
    }

    int main() {

    stack1();

    printf("%d goes 1\n", getpid());
    stack2();

    printf("%d goes 2\n", getpid());
    return 0;
    }

  • 父进程stack1后阻塞在vfork()调用,子进程从vfork()返回,并执行stack2()并退出, 在执行stack2()之前子进程会将main函数的返回地址改为stack2()的下一句,所以父进程从stack1()返回时,会执行goes2语句,很神奇!
  • exit()和_exit()的基本区别在于前者执行与库中用户模式结构相关的清理,并调用用户提供的清理函数,而后者仅执行进程的内核清理,比如关闭所有文件描述符。

  • 在fork()的子分支中,通常不使用exit(),因为这可能导致stdio缓冲区被刷新两次,并且临时文件被意外删除。在C++代码中,情况更糟,因为静态对象的析构函数可能会被错误地运行。(有一些不寻常的情况,如守护进程,其中父进程应调用_exit()而不是子进程;适用于绝大多数情况的基本规则是:每次进入main时只应调用一次exit()。)

  • 在vfork()的子分支中,使用exit()甚至更加危险,因为它会影响父进程的状态。

  • 从glibc 2.27开始,abort()函数终止进程时不会刷新流。POSIX.1允许两种可能的行为,即abort()函数“可能会尝试对所有打开的流执行fclose()操作”。

    • std::terminate调用的就是abort()函数

linux高性能服务器的搭建
http://example.com/2023/11/03/linux-high-performanceserver/
作者
李凯华
发布于
2023年11月3日
许可协议