常识—-Linux多线程服务器端编程

  • Post author:
  • Post category:linux



1.进程与线程区别


进程和线程是操作系统中的两个核心概念,它们有以下区别:

  1. 定义:进程是程序的执行实例,是资源分配和调度的基本单位,拥有独立的内存空间和系统资源。线程是进程内的执行单元,是CPU调度的基本单位,共享所属进程的内存空间和系统资源。
  2. 资源占用:每个进程都有独立的内存空间和系统资源,包括文件描述符、打开的文件、网络连接等。而线程共享所属进程的内存空间和系统资源,它们可以直接访问进程内的全局变量和共享数据。
  3. 切换开销:由于进程拥有独立的内存空间和系统资源,进程间的切换开销较大。切换进程需要保存和恢复整个进程的上下文信息。而线程共享进程的内存空间,线程间的切换开销较小,只需要保存和恢复线程的上下文信息。
  4. 并发性:多个进程之间是并发执行的,每个进程都有自己的执行顺序和状态。而线程是在进程内部并发执行的,多个线程共享进程的执行顺序和状态。
  5. 通信方式:进程间通信需要使用特定的机制,如管道、消息队列、共享内存等。而线程之间可以直接通过共享内存和全局变量等方式进行通信。
  6. 容错性:由于进程间是独立的,一个进程的崩溃不会影响其他进程的执行。而线程共享进程的内存空间,一个线程的错误可能会导致整个进程的崩溃。

    总的来说,进程是资源分配和调度的基本单位,拥有独立的内存空间和系统资源;而线程是进程内的执行单元,共享进程的内存空间和系统资源。线程的切换开销小,但容错性较差;而进程的切换开销大,但容错性较好。在实际应用中,进程和线程的选择取决于具体的需求和性能要求。


2.线程同步的方式:互斥锁、自旋锁、读写锁、条件变量


线程同步是指多个线程之间按照一定的顺序和规则共享资源或进行合作的过程。以下是常见的线程同步方式:

  1. 互斥锁(Mutex):互斥锁是一种最常见的线程同步机制,通过对共享资源加锁来保证同一时间只有一个线程可以访问该资源,其他线程需要等待锁释放。互斥锁可以防止多个线程同时访问共享资源,保证数据的一致性和完整性。
  2. 自旋锁(Spinlock):自旋锁是一种忙等待的线程同步机制,当线程尝试获取锁时,如果锁已经被其他线程占用,该线程会一直循环等待,直到获取到锁。自旋锁适用于锁被占用时间较短的情况,避免了线程上下文切换的开销。
  3. 读写锁(ReadWrite Lock):读写锁允许多个线程同时读取共享资源,但只允许一个线程写入共享资源。当有线程正在写入时,其他线程无法读取或写入。读写锁适用于读操作远远多于写操作的场景,可以提高并发性能。
  4. 条件变量(Condition Variable):条件变量用于线程之间的通信和协调,通过等待和唤醒机制来实现线程的同步。线程可以通过条件变量等待某个条件满足,当条件满足时,其他线程可以通过条件变量的唤醒操作通知等待线程继续执行。

    这些线程同步方式可以根据具体的应用场景选择合适的方式来实现线程间的同步和协作,保证数据的正确性和线程的安全性。


3.互斥锁与自旋锁的底层区别


互斥锁和自旋锁都是用来保护共享资源的锁,但它们的底层实现方式有所不同。

互斥锁是一种阻塞锁,当有线程占用锁的时候,其他线程需要等待,直到原来的线程释放锁以后才能获得锁。在Linux中,互斥锁的实现使用了futex(fast userspace mutex)系统调用,当线程需要获取锁时,如果锁没有被占用,线程会立即获得锁;如果锁已经被占用,线程会将自己加入到等待队列中,并且调用futex系统调用将自己设置为休眠状态,直到其他线程释放锁并唤醒它。

自旋锁是一种忙等锁,当有线程占用锁的时候,其他线程会不停地尝获取锁,直到锁被释放。在Linux中,自旋锁的实现使用了原子操作指令(如CAS、XCHG等),当线程需要获取锁时,它会不停地尝试修改锁的状态,直到成功修改为锁可用。

因为自旋锁不涉及线程的上下文切换,所以它的效率比互斥锁更高。但是,如果锁的占用时间较长,自旋锁会一直占用CPU资源,造成浪费。而互斥锁在占用时间较长时会将等待的线程挂起,等到锁被释放后再唤醒线程,相对于自旋锁有更好的表现。

另外,自旋锁只适用于多核处理器,因为在单核处理器上自旋等待会导致其他线程不能运行,造成死锁。而互斥锁则可以在单核或多核处理器上使用。


4.孤儿进程与僵尸进程


孤儿进程和僵尸进程是两种不同的进程状态。

孤儿进程是指其父进程已经退出或被终止,而子进程还在运行的进程。孤儿进程由init进程(pid为1的进程)接管并称为init进程的子进程。init进程会定期扫描系统中的孤儿进程并将其回收资源,以防止资源泄露。

僵尸进程是指已经但是父进程尚未回收其资源的进程。在进程结束时,内核会保存一段时间进程的一些状态信息以便父进程在以后的某个时间点获取这些信息(例如进程的退出状态码)。这段时间被称为僵尸状态。如果父进程没有主动回收这些资源,这些进程就会一直处于僵尸状态,占用系统资源。可以通过调用waitpid()来回收子进程资源,避免出现僵尸进程。

总的来说,孤儿进程和僵尸进程都会占用系统资源,并有可能导致系统崩溃,因此需要及时处理。处理孤儿进程可以通过定期扫描系统并回收资源来避免资源泄露,处理僵尸进程可以通过回收资源来避免系统资源的浪费。


5.死锁及避免


死锁是指两个或多个进程互相等待对方释放资源,导致所有进程都无法继续运行的状态。要避免死锁,可以考虑以下几点:

  1. 避免占用过多资源:减少进程需要占用的资源数量,例如限制最大并发数或减少复杂度。
  2. 避免资源竞争:多个进程需要用到同一个资源时,可以设置优先级,先让一个进程获取到资源后再由其他进程争夺。
  3. 避免循环等待:如果多个进程需要获取多个资源,可以统一申请资源,并按统一的顺序释放,在此过程中不通过等待获取资源,从而避免死锁。
  4. 使用超时机制:在资源等待一定时间后,可以强制释放资源并停止不必要的操作,避免死锁持续占用资源。
  5. 使用死锁检测和恢复算法:在程序中实现死锁检测和恢复算法,即在死锁发生时自动发现并进行恢复,从而避免程序长时间无响应,提高程序的鲁棒性。


6.多线程与多进程比较


多线程和多进程都是提高程序运行效率的方法,但它们之间也存在一些差异。

  1. 运行效率: 在资源利用上,多线程比多进程要高效,由于多线程在同一个进程内,所以它们是共享内存的,创建、销毁线程和切换线程的代价相对较小。但是多进程在不同的进程之间,需要更多的资源和开销。
  2. 实现方式:多线程在向单个进程添加线程时要简单得多,因为线程共享内存并共享大部分状态。多进程则需要额外的安全机制,如信号和套接字或管道来实现进程之间的通信。
  3. 稳定性:多线程相比多进程,稳定性更佳,因为一个进程中所有线程都共享同一个内存空间,且单个线程的错误不太可能影响整个进程或系统。但是多进程可以较为容易地实现分布式计算,各个进程之间运行互不干扰,各自独立运行,这样就有利于提高整个系统的稳定性。
  4. 调试难易程度:多线程比多进程更容易调试,因为线程可以共享大多数状态,并且它们可以同步、协作和相互通信。多进程之间信息互相隔离,调试难度相对比较高。
  5. 编程难度:多线程编程可能会涉及到一些 synchronization 和 concurrency issues ,例如 race condition、deadlocks 等,因此需要开发者在编写代码时特别注意多线程的安全性。

    总的来说,选择多线程还是多进程,应该根据具体的应用场景进行取舍。需要综合考虑到资源利用、稳定性、并发性、调试难度以及编程难度等方面的因素来做一个权衡。


7.进程间通信:PIPE、FIFO、消息队列、信号量、共享内存、socket


进程间通信(IPC,Inter-Process Communication)是指两个或多个进程之间进行数据交换或资源共享的过程。在Unix/Linux操作系统中,经典的进程间通信方式包括PIPE、FIFO、消息队列、信号量、共享内存和socket等方式。

  1. PIPE:PIPE是一种半双工的通信方式,只能在有亲缘关系或在同一进程时使用。创建PIPE需要调用pipe()函数,一个PIPE既可以从父进程向子进程传送数据,也可以从子进程向父进程传送数据。
  2. FIFO:FIFO是一种进程间通信方式,也称命名管道。它是一种特殊的文件,任何进程可以打开它,同时它遵循先进先出(FIFO)的原则。FIFO同PIPE的区别是FIFO是有名管道,而PIPE是无名管道。
  3. 消息队列:消息队列是一种进程间通信机制,在多个进程之间传递数据,同步进程的运行。消息队列在系统中的表现形式是一个消息队列列表,每个消息队列都有一个唯一的标识符,用于标识该消息队列。
  4. 信号量:信量是一种进程间通信方式,它是一段被系统设置的整型变量,可以用于进程间同步和互斥操作。它主要有两个操作,一是P操作,即进程请求资源,它会判断资源是否被占用,并锁定资源;二是V操作,即释放资源,将占用的资源锁释放。
  5. 共享内存:共享内存是一种高效的进程间通信机制,它允许两个或多个进程共享同一段物理内存。多个进程可以将同一数据结构映射到它们自己的地址空间中,并且都可以读写该内存。
  6. socket:socket是一种进程间通信方式,它是一种支持TCP/IP协议的进程通信机制,能够在不同的进程之间进行网络通信。通过socket,进程可以通过IP地址和端口号进行通信。

    综上,各种进程间通信方式都有各自的优缺点和适用场景。在实际应用中,需要根据具体的场景和需求选择合适的进程间通信方式。


8.管道与消息队列对比


管道和消息队列都是进程间通信机制,但二者也存在着一些不同点。

  1. 线性数据传输:管道只能进行线性数据传输,而消息队列可以进行非线性的数据传输。
  2. 数据传输方式:管道是一种半双工的通信方式,只能在有亲缘关系或在同一进程时使用;消息队列可以在多个进程之间传递数据。
  3. 数据传输的属性:管道通常是面向字节流的,没有特定的格式,数据不会被截断,空间有限,有可能会造成阻塞;而消息队列发送的消息是已经打包好的,有消息类型属性,可以避免数据被截断,数据写入缓存区即可返回,非常灵活。
  4. 数据传输的可靠性:为了保证数据传输的可靠性,消息队列在发送数据时,可以设置消息队列的长度、消息类型、优先级等属性。而管道的传输完全依赖于操作系统的缓存和处理,难以保证数据的可靠性。
  5. 消息存储:在管道中,数据经常在写入和读取之间丢失,所以需要确保读写双方都在线,如果其中一方关闭了,可能会导致数据流失。而消息队列中的消息存储在内核中,可以在进程退出后仍然存在,具有非常好的消息存储性能。

    综上,管道和消息队列都有各自的优点和不足,应该根据具体的应用场景和需求选择合适的进程间通信方式。对于高性能、高并发、容错性高、消息数据复杂、消息处理流程灵活的场景,使用消息队列会更为适合。对于简单的数据传输场景,使用管道更为简单和高效。


9.fork进程的底层:读时共享,写时复制


在Linux操作系统中,当进程调用fork()系统调用创建一个子进程时,该子进程会被复制出一个完整的进程实体,包括了该进程的代码段、数据段、进程堆栈、文件描述符表、信号处理程序等,但是这些数据结构并不是简单地被完全复制的,其实是通过一种“读时共享、写时复制”的技术进行处理的。具体来讲:

  1. 读时共享:在子进程和父进程之间,只要没有发生修改操作,它们所指向的内存空间对于系统是只读的。也就是说,当一个进程通过指针访问一个地址时,如果它只是读取这个地址中的数据而没有修改这个数据,那么实际上它们都在“读”同一个内存地址,即共享相同的内存空间。这就是子进程读时共享的机制。
  2. 写时复制:如果父进程中某一个进程对于一个共享的资源进行了修改,则系统会为该进程分配一个新的内存空间来保存这个修改后的资源,这个分配操作也就是“写时复制”。这样,当父进程中某个子进程进行了修改和资源的分配后,父进程和其他子进程仍然可以访问原来的内存资源,因为这些资源是只读的。

    综上,读时共享、写时复制的技术可以在子进程创建时减少系统的资源开销。在子进程中对于资源的修改不会影响到父进程和其他进程的调用,因为它们会各自拥有独立的资源副本。


10.线程上下文切换的流程


线程上下文切换是指从一个线程切换到另一个线程执行的过程。下面是一般情况下线程上下文切换的流程:

  1. 当前线程执行到了某个时刻必须放弃 CPU,例如等待 I/O 完成或者时间片用完。此时,内核会检查是否有其它相应线程需要运行。
  2. 如果有,内核就会挑选一个最优先的线程,然后将当前线程的上下文保存起来。上下文包括寄存器的值、程序计数器和堆栈指针等信息,以便于下次切换回当前线程时,能够恢复执行。
  3. 内核会把 CPU 分配给新选中的线程,并将该线程的上下文加载到 CPU 的寄存器、程序计数器和堆栈指针中。
  4. 新选中的线程从之前被挂起的地方开始执行。在执行过程中,如果需要系统调用或发起陷入,该线程也会与内核发生交互,切换到内核的上下文中执行。
  5. 当线程的时间片结束时,回到第 1 步重新开始上述流程。

    上下文切换的开销是非常高的,因为需要保存和恢复大量的 CPU 上下文信息。因此,在开发多线程应用程序时需要尽量减少上下文切换的次数。


11.进程的调度算法


进程调度算法是操作系统中用于决定哪个进程可执行的一种策略。常用的进程调度算法有以下几种:

  1. 先来先服务(FCFS)算法:

    按照进程申请 CPU 的先后顺序来安排进程执行,即先到先服务。这种算法具有简单、公平的优点,但因其试图将对 CPU 的占用分给每一个进程,因此长作业会对短作业产生非常不公平的结果,造成短作业响应时间过长。
  2. 短作业优先(SJF)算法:

    该算法是根据作业估计时间或实际运行时间,把短作业先调度执行,等到后面才去调度长作业执行。同时该算法还可以分为非抢占式和抢占式。非抢占式短作业优先算法是指作业在运行过程中,不能由其他作业抢占 CPU;抢占式短作业优先算法则是指作业在运行过程中,如果有更短的作业需要执行,那么当前作业就会被抢占。
  3. 优先级算法:

    为每个进程赋予一个优先等级,然后按照优先级的高低顺序来进行调度。该算法可以是抢占式或非抢占式。
  4. 时间片轮转算法:

    将 CPU 的使用权分配给若干进程,每个进程能使用 CPU 的时间片长度为固定值。当该进程用完时间片后,系统强制暂停执行并将 CPU 给下一个进程,进程的状态被保存下来,进程回到就绪队列的队尾,等待重新被调度。
  5. 多级反馈队列调度算法:

    如名字,将就绪进程分为若干个就绪队列,每一个队列采用不同时间片的轮转算法,高优先级队列的时间片较短,优先执行,低优先级队列的时间片较长。进程在每个队列中按该队列时间片的大小来轮转,如果在时间片结束前进程运行完成,则该进程退出队列,否则降低优先级并放到相应低优先级队列的队尾。


12.阻塞IO与非阻塞IO


阻塞 I/O 和非阻塞 I/O 是指 I/O 操作的两种不同模式:

  1. 阻塞 I/O:

    在执行 I/O 操作的时候,程序会卡在 I/O 操作上,一直等待 I/O 操作返回结果才会继续执行。在这种模式下,程序的 CPU 时间大部分被浪费在等待 I/O 操作上。例如,执行一个读取大文件的操作,需要等待长时间的磁盘 I/O 才能完成。
  2. 非阻塞 I/O:

    非阻塞 I/O 模式下,程序发起一个 I/O 操作后,不会停止程序的执行,而是继续运行下一条指令。程序需要通过轮询等方式来判断 I/O 操作是否完成。这种模式下可以在等待 I/O 返回的同时执行其他任务。

    一般情况下,阻塞 I/O 的性能较低,非阻塞 I/O 的性能较高。非阻塞 I/O 适用于并发较高,需要快速响应的场景,例如网络服务器等。但是,非阻塞 I/O 带来的问题是需要对 I/O 操作进行手动轮询,增加了程序代码复杂度。因此,在实际开发中需要根据场景选择适合的 I/O 模式。


13.同步与异步的概念


同步和异步指的是程序或系统对任务处理方式的不同方法。

  1. 同步:

    同步指的是任务依次执行,完成了一个任务才会开始执行下一个任务。在同步模式下,程序在执行一项任务时会一直等待直到该任务完成,然后才能执行下一项任务。
  2. 异步:

    异步指的是任务可以同时进行,不需要等待上一个任务的完成。在异步模式下,程序不会一直等待某一个任务执行完毕,而是在任务执行的同时也可以执行其他任务,可以提高程序的执行效率。

    举个简单的例子,如果需要从文件中读取数据并进行处理,同步模式下,程序会一直等待文件读取完成并得到数据后才能进行处理;而异步模式下,程序会在读取文件的同时并行进行处理。

    同步和异步的选择,需要根据具体场景而定。同步模式通常适用于要求处理结果实时返回的场景,异步模式则适用于需要并发执行的场景,可以提高系统的运行效率。


14.静态链接与动态链接的过程


静态链接和动态链接是将多个目标文件或者库文件连接成可执行文件的两种方式。

  1. 静态链接:

    静态链接指的是在编译时将所有目标文件和库文件集成到一个单独的可执行文件中。在静态链接中,目标文件中的函数和资源会被直接复制到可执行文件中,并且不需要外部的库文件的支持。在执行过程中,所有需要的代码和数据都从可执行文件中读取,可以避免对系统库的依赖,因此也可以避免一些潜在的安全问题。
  2. 动态链接:

    动态链接指的是在程序运行时才将需要的库文件加载到内存中,而不是在编译时将库文件链接到可执行文件中。在动态链接中,程序运行时需要依赖于外部库文件,系统会根据需要动态加载依赖的库文件到内存中,并将需要使用的代码和数据在运行时从库文件中复制到内存中,节省了空间和磁盘的资源消耗。

    在实际开发中,通常使用动态链接,因为它可以节省磁盘和内存空间,同时还可以提高程序开发效率。另外,在开发过程中可以使用静态链接进行测试和调试,最终将代码部署到生产环境时采用动态链接。


15.虚拟内存概念(非常重要)


虚拟内存是一种操作系统技术,它将内存的物理地址和逻辑地址分开管理,让应用程序认为自己独享整个计算机的内存,从而实现更高效、更安全、更灵活的内存管理。

虚拟内存的原理是,在应用程序和物理内存之间增加了一个软件层,称为虚拟内存管理器。虚拟内存管理器将内存空间分割成一些固定大小的页面(page),并在应用程序和物理内存之间建立一个虚拟到物理的地址映射表。当应用程序需要访问内存时,它只需要向虚拟内存管理器请求访问一个地址,虚拟内存管理器会将其转换为物理地址,并将所需的页面加载到内存中。

虚拟内存的优点包括:

  1. 更高效的内存管理:虚拟内存缩短了与硬件操作的距离,减少了操作系统与硬件之间的沟通成本,提高了内存管理的效率。
  2. 更安全的内存管理:虚拟内存为每个进程提供了一个独立的地址空间,防止了进程之间的内存干扰。同时,虚拟内存使操作系统可以更好地控制对内存的访问,减轻了恶意程序对系统的破坏。
  3. 更灵活的内存管理:虚拟内存允许应用程序使用比物理内存更大的地址空间,从而支持更大的程序和更复杂的数据结构。此外,虚拟内存还支持内存交换(swap),即将不需要的页面移到硬盘上,从而释放出内存空间。

    尽管虚拟内存有很多优点,但是也带来了一些缺点,比如增加了内存管理的复杂性,增加了硬件的负担等,因此在设计和实现操作系统时需要平衡虚拟内存的优缺点,从而达到系统性能、可靠性和安全性之间的平衡。


16.MMU地址翻译的具体流程


MMU(Memory Management Unit)是一种硬件设备,用于将应用程序所使用的逻辑地址翻译为物理地址。在现代计算机系统中,所有的内存访问都经过MMU进行地址翻译和权限检查,以保护系统的安全性和稳定性。

MMU的地址翻译流程如下:

  1. 当应用程序执行一条内存读或写指令时,该指令中包含一个逻辑地址(也叫虚拟地址)。
  2. MMU根据操作系统指定的地址映射规则将逻辑地址转换为物理地址。这个规则通常由操作系统的内核维护,包括页表(Page Table)和地址空间(Address Space)。
  3. MMU使用逻辑地址中的高位作为索引,查找页表中相应的页表项。页表项包括两个主要信息:物理页框号和页面属性。
  4. 如果找到该页表项,并且该页是合法的(如未被锁定或已被加载到物理内存)则MMU使用页表项中指定的物理页框号和逻辑地址中的低位作为偏移量,计算出物理地址。
  5. MMU将物理地址传递给总线,让总线控制器将该地址发送到主存储器中读取或写入数据。

    这就是MMU地址翻译的具体流程。值得注意的是,虚拟地址的高位与物理地址的高位并不相同,这意味着同一个虚拟地址可以在不同的进程和不同的时间中映射到不同的物理地址,从而实现了内存地址的隔离和保护。


17.缺页处理过程


缺页(Page Fault)是指访问一个没有分配物理内存的虚拟地址,这时操作系统需要将该页面加载到内存中。缺页处理是一种重要的内存管理技术,其主要流程如下:

  1. 硬件检测到一个缺页异常时,会引起CPU切换到内核态。此时硬件会保存当前进程的状态,并将控制权传递给操作系统。
  2. 操作系统会检查访问的地址是否合法,然后检查进程的页表(Page Table)是否包含该页的映射。如果页表无法解析该虚拟地址,则进入缺页异常处理流程。
  3. 在缺页异常处理流程中,操作系统会查找空闲的物理页框(Page Frame)。如果有空闲的物理页框,则分配给当前进程。否则,操作系统需要选择一个物理页框,并将其中一个原有的页面交换到磁盘上,从而腾出空间。
  4. 一旦有了物理页框,操作系统就会读取磁盘上的缺页所需的页面,并将其写入到物理页框中。这个过程中的读写磁盘操作,称为页面置换(Page Replacement)。
  5. 如果操作系统在步骤2中检测到访问地址非法,则会终止当前进程并报告错误。否则,操作系统把物理页框号添加到当前进程页表中,并重新发出进程已被中断前要处理的指令。此时CPU重新切回用户态,从上一步断开处继续执行。
  6. 如果在步骤4中发现了需要页面置换的,则此时还需要更换页表中与已经交换出去的原有的页面的映射。

    缺页处理是一种非常常见的操作系统技术,可以使系统更有效地利用内存空间,并提高性能。但如果频繁发生缺页会影响系统的响应速度,所以在设计和实现操作系统时,需要根据具体应用场景权衡处理缺页的策略和机制。


18.缺页置换算法:最久未使用算法、先进先出算法、最佳置换算法


缺页置换算法是操作系统在缺页异常时选择要置换的页面的一种策略,三种常见的算法有:

  1. 最久未使用算法(Least Recently Used,LRU):该算法将保留时间最短的页面作为置换候选页面,即选择最久未被使用过的页面。实现比较复杂,需要记录每个页面最近一次被使用的时间,占用空间较多。但相对于其他的置换算法,LRU更好地利用了局部性原理,减少了程序产生缺页的频率,因此通常具有更好的性能。
  2. 先进先出算法(First In First Out,FIFO):该算法按照页面进入内存的时间先后顺序选择置换候选页面,即选择最先进入内存的页面进行置换。实现比较简单,但无法考虑页面使用的频率和重要性,可能会将最活跃的页面也置换掉,影响程序性能。
  3. 最佳置换算法(Optimal):该算法是基于未来预测的思想,即选择未来最长时间不会使用的页面进行置换。理论上,该算法可以达到最优的效果,但实际上,由于难以准确预测未来的页面访问模式,因此该算法往往比较难以实现。

    需要注意的是,不同的置换算法适用于不同的场景和应用,需要结合实际情况进行评估和优化。此外,在实际应用中,还有其他一些较为复杂的缺页置换算法,如时钟算法、二次机会算法等,这些算法也需要根据具体情况进行选择,以提高系统的性能和效率。


19.IO多路复用:select、poll、epoll的区别(非常重要,几乎必问,回答得越底层越好,要会使用)


IO多路复用是指通过一种机制,使单个进程可以监视多个文件描述符(Socket、文件等)的可读可写状态,从而实现对多个IO操作的同时监听。主要有以下三种实现方式:

  1. select:是最早引入的IO多路复用函数,支持所有平台,使用了基于数组的存储结构,可以监测到的文件描述符数量存在一定的限制,并且每次调用都需要传递整个数组,导致效率较低。
  2. poll:相较于select实现更加简单,不需要复制整个描述符集,支持的文件描述符数量也比较大,但也受到了单个进程可监听的文件描述符数目的限制。
  3. epoll:是Linux下实现IO多路复用的最优方案,具有更好的性能和扩展性,可以使用Level Triggered(LT)和Edge Triggered(ET)两种模式,支持单个进程同时监控大量(上百万)的文件描述符。与前两者不同的是,epoll采用了事件驱动的方式,当有事件发生时,只会返回活跃的文件描述符,避免了遍历整个描述符集和复制的操作,很大程度上提高了应用程序的效率。

    总的来说,三种IO多路复用机制都可以为应用程序提供高效、异步IO操作的支持,但在单个进程需要并发处理大量IO操作时,epoll是效率和扩展性最优的选择,也是目前最常使用的IO多路复用机制。


20.手撕一个最简单的server端服务器(socket、bind、listen、accept这四个API一定要非常熟练)

import socket

def main():
    # 创建socket对象
    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    
    # 绑定ip和端口号
    server_socket.bind(('127.0.0.1', 8000))
    
    # 监听端口号
    server_socket.listen(128)
    
    while True:
        # 接受客户端socket连接请求,并返回新的socket对象及客户端的地址
        client_socket, client_addr = server_socket.accept()
        
        # 从客户端socket中读取数据
        recv_data = client_socket.recv(1024)
        print(recv_data.decode('gbk'))
        
        # 向客户端socket发送数据
        client_socket.send('Welcome to the server!'.encode('gbk'))
        
        # 关闭客户端socket连接
        client.close()

if __name__ == '__main__':
    main()

步骤如下:

  1. 首先创建socket对象;
  2. 然后将ip和端口号绑定到已创建的socket对象;
  3. 监听端口号;
  4. 进入循环,等待客户端socket请求连接;
  5. 当客户端socket请求连接时,accept()方法将会阻塞,直到收到连接请求;
  6. 获取连接请求后,从客户端socket中读取数据,并向客户端返回信息;
  7. 关闭客户端socket连接。

这是一个基本的Server服务器,没有做异常处理和多线程等复杂操作,仅用于演示简单的socket编程过程。


21.线程池


线程池是一种多线程处理方式,它包含了两个核心部分:一是线程池管理器,二是线程池。线程池管理器负责生成、启动和销毁线程池,线程池则负责管理、调度和执行线程任务。

线程池的主要优点是避免了线程的频繁创建和销毁,因为此过程是需要消耗比较多的系统资源的。相反,线程池中的线程可以被重复利用,从而可以更好地管理线程,并减少系统开销。

线程池的主要工作流程如下:

  1. 初始化线程池,包括设置线程个数、初始化线程队列等;
  2. 当有新的任务需要处理时,从线程池中获取一个可用的线程,将任务交给它处理;
  3. 线程处理完任务后,将结果返回给请求方,并且继续待命;
  4. 如果线程处于空闲状态超过设定的时间,线程可能会被销毁,从而释放系统资源。

    线程池的实现可以使用操作系统提供的线程池,也可以根据需要自己实现。一般来说,实现一个线程池需要考虑以下几个方面:
  5. 线程池的大小和任务队列的大小,并发任务的最大个数;
  6. 线程池中线程的状态,如果某个线程异常退出,应该如何处理;
  7. 合理的任务分配策略,例如任务的优先级等;
  8. 健壮的线程管理和线程通信方式;
  9. 充分的错误处理和日志记录。

    线程池应用广泛,尤其是在Web服务和网络编程方面使用较多。多个线程的并行处理能够大大提高系统的性能和吞吐量,因此在实际生产和开发中,线程池已经变得越来越重要。


22.基于事件驱动的reactor模式


基于事件驱动的reactor模式是一种网络编程模式,通常用于高性能、低延迟、大并发的网络通信中。这种模式的核心是将I/O(读写)操作抽象为事件,由事件驱动框架触发事件并调用相应的处理函数。

其中reactor是指事件驱动的核心框架,其主要功能是监听文件描述符(如socket),当文件描述符可读或可写时,reactor会回调预先注册的Handler将处理方法挂在事件循环中。事件循环主要负责响应事件并执行相应的回调,以完成I/O操作。

在reactor模式中,每个通信连接都会对应一个Handler对象,负责处理该连接的读写事件,并将事件添加到事件循环中。事件循环通过轮询所有注册的事件,来执行相应的I/O操作,以及检查新连接的到来,从而实现高效的网络通信。

reactor模式的优点是高性能、高并发、低延迟和易于扩展,尤其在网络编程中,可以极大地提升系统性能。在实际应用中,比较出名的reactor编程框架有Twisted、Tornado、Netty等。


23.边沿触发与水平触发的区别


在事件驱动的编程模型中,边沿触发和水平触发是两种常用的I/O事件触发方式。

边沿触发:

在边沿触发(Edge-Triggered)机制中,当一个I/O事件发生时,系统会通过相应的回调函数通知用户。此时用户需要尽可能地处理事件,并检查是否还有剩余数据。如果有剩余数据需要处理,则需要挂起该事件,等待下一个边沿触发时再次处理。

边沿触发的特点是:只有在状态发生改变(如有新数据到来)时才会触发回调,对于大量的事件处理效率更高。

水平触发:

在水平触发(Level-Triggered)机制中,当一个I/O事件发生时,系统会反复地通知用户,直到用户处理完全部数据。这种机制要求用户需要不断地处理事件。如果未处理完数据,则沿用之前的事件处理方式,重复通知用户处理该事件。

水平触发的特点是:只要数据可读或可写,就会不断触发回调,需要用户不断进行处理。

总的来说,边沿触发相对于水平触发,更加高效、更加节省系统资源,但是也需要更加细致的设计和编程,以保证所有事件都能得到处理。而水平触发则相对简单,但在处理大量数据的情况下,可能会对系统性能产生较大的影响。


24.数据存储引擎:InnoDB、myISAM、Memory


InnoDB、MyISAM和Memory都是MySQL的存储引擎,分别有不同的特点和优势。

InnoDB是MySQL的一个事务安全的存储引擎,支持ACID事务和行级锁,并发能力较强,适合处理大规模的数据和高并发的读和写。同时,InnoDB对于数据的完整性有一定的保障,通过支持外键约束和回滚日志(redo log和undo log),可以保证即使出现了异常情况,也能够进行回滚恢复,因此在数据层面的可靠性较高。但是,InnoDB的写入效率相对不高,需要经常进行磁盘操作,且索引需要较多的存储空间。

MyISAM是MySQL的一个较为简单的存储引擎,适用于大量插入和查询的场景。MyISAM支持全文本索引,可以对文本数据进行高效的搜索,但不支持ACID事务和行级锁,对于数据的完整性和并发能力较差。因此,MyISAM通常用于不需要频繁写入和事务的应用,如日志记录等。

Memory是MySQL的一个内存存储引擎,数据都存储在内存中,因此读取速度较快且对于高并发读写也具有较好的性能。Memory支持hash索引,但不支持B+树索引,因此在查询方面有一些限制。同时,Memory对于数据的完没有MyISAM和InnoDB那么强,不能保证数据持久性,而缓存控制和内存使用也需要进行一定的调整。

选择合适的存储引擎需要考虑到具体的应用场景和要求,包括数据容量、读写频率、事务和索引等方面的要求和限制。


25.数据库索引类型及原理:B+树索引、哈希表索引


数据库索引是一种用于提高数据库查询效率的数据结构。常见的数据库索引类型有B+树索引和哈希表索引。

B+树索引是一种基于B树的索引结构,通常用于查询范围较广的数据。它的原理是将数据按照顺序存放在节点中,通过二分法快速查找所需数据。B+树索引的最底层节点保存实际数据,而非叶子节点只存放指针,这样可以很快地找到对应的数据,同时也避免了非叶子节点的频繁读写操作。B+树索引还具有良好的可扩展性,可以支持海量数据的存储和处理。

哈希表索引是利用哈希算法将关键字映射到表中的一个位置,以加快查找的速度。哈希表的原理是将关键字与记录的地址进行映射,然后将映射结果作为索引访问表格。哈希表索引具有快速查找数据的优势,适用于查询单个记录时。但是,哈希表索引不支持范围查询,而且在数据量大时可能会出现哈希冲突,影响查询效率。因此,哈希表索引适用于存储相对较少的数据。


26.锁:悲观锁、乐观锁


锁是一种并发控制机制,用于保护共享资源的正确访问,防止多个线程同时读写共享资源导致数据不一致或产生其他问题。

悲观锁:

悲观锁指的是在执行操作之前,先加锁,后释放锁,即认为当前数据可能被其他线程修改,因此先将数据锁定,在执行操作完成后再释放锁并解除锁定。悲观锁的特点是:使用时锁定资源,效率较低,但是可以保证数据操作的安全性。

常见的悲观锁有互斥锁,在Java中就是synchronized关键字,还有ReentrantLock。

乐观锁:

乐观锁指的是先读取数据版本号,然后在执行操作时,重新读取数据的版本号,如果两次读取的数据版本号相同,则认为没有其他线程对数据进行修改,可以进行修改操作,否则视为操作失败,并且需要重试。乐观锁的特点是:不使用锁,效率较高,但是需要保证所有操作都是并发安全的。

常见的乐观锁有AtomicInteger、CAS机制、版本号机制等。

总的来说,悲观锁和乐观锁都有各自的优点与缺点,具体使用哪种锁应该根据具体情况选择。在Java 8中,对于一些并发操作,如CAS、原子操作,语言本身已经提供了丰富的支持,可以大大简化编程工作。


27.事务:事务的四大特性(ACID)、事务并发的三大问题、事务隔离级别及实现原理


事务是指由一系列要么全部执行,要么全部不执行的操作所组成的逻辑处理单元,是保证数据一致性的重要手段。事务通常具有四大特性(ACID):原子性(Atomicity)、一致性(Consistency)、隔离性(Isolation)和持久性(Durability)。

事务并发的三大问题是:脏读、不可重复读和幻读。脏读指的是未提交的数据被其他事务读取,而这些数据可能随时会被回滚;不可重复读指的是在同一事务中,读取同一数据多次,但由于并发操作导致数据被修改,结果读取的结果不同;幻读指的是在某个时间段内,一个事务多次读取同一数据,但其他事务不断地插入新行,导致第一个事务看到的行数不同。

为解决以上并发问题,事务通过设置隔离级别来实现。事务隔离级别分为四种:read uncommitted(未提交读)、read committed(提交读)、repeatable read(可重复读)和serializable(串行化)。其实现原理主要是通过锁机制来实现隔离,通常有两种锁:共享锁和排他锁。

在实现事务隔离时,通常会引入锁的机制,来限制对共享资源的并发访问,从而保证所定义的隔离级别的正确性。在多数数据库中,读锁和写锁均采用“共享-排他锁(Shared Lock-Exclusive Lock)”机制来实现。同时,对于一些特殊的情况,如死锁、饥饿等,需要通过特定的方法来进行监测和处理,以保证事务能够安全、正确地执行。


28.多版本并发控制实现机制(MCVV)原理


多版本并发控制(MVCC)是一种保证事务隔离性的并发控制机制。MVCC 把每次修改的数据都保存下来,形成对应的版本号,每个事务在启动时会读取一个版本号号段,以保证这个事务内所需读取的所有数据都是同一时间点上的一个快照。

MVCC 返回的快照数据都是一致性的,但其实现方式和其他并发控制机制是有很大不同的。在 MVCC 中,每个事务都可以看到不同的数据版本,而不是最新的数据。在读取一个数据时,事务会获取该数据的快照版本,并且在本次事务内都只能访问到这个快照版本的数据。如果需要修改数据,MVCC 还会把修改后的数据保存为另一个版本,而不是立刻覆盖原来的版本。这样就可以达到多版本并行操作的目的。

MVCC 的实现方式通常是基于行级别的锁定机制,每次读取数据时,会将当前事务的版本号与读取到的数据的版本号进行对比,如果当前事务的版本号比读取到的数据的版本号旧,那么就需要重新读取数据,直至获取到当前事务的最新版本,从而保证读取数据的一致性。

MVCC 实现机制的优点包括:并发能力强、读写操作互不干扰、无需阻塞和等待。但也需要支付额外的存储、查询代价等,实际应用中需要根据应用场景进行权衡。



版权声明:本文为m0_37975258原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。