Linux之线程互斥

线程简单封装

试着用线程控制力介绍的一些系统调用, 将线程的创建、执行和等待等都封装起来. 我们在程序中指定一个函数Print, 让多个线程不断地执行该函数.

myThread.hpp

cpp 复制代码
#pragma once
#include <functional>
#include <pthread.h>
#include <string>

//假定线程内部的函数类型是void(T)类型的
template<class T>
using func_t = std::function<void(T)>;

template<class T>
class Thread
{
public:
    Thread(const std::string& name, func_t<T> func, T data)
    :_tid(0)
    ,_name(name)
    ,_func(func)
    ,_flag(false)
    ,_data(data)
    {}

    //由于pthr_create内函数是void*(void*)类型, 所以需要把_func封装
    //由于成员函数默认第一个参数是this, 所以设置为静态取消this, 但是需要把this作为参数传递进来
    static void* ThreadRoutine(void* arg)
    {
        Thread* ts = static_cast<Thread*>(arg);
        ts->_func(ts->_data);

        return nullptr; 
    }    

    //获取线程名称
    std::string getThreadName()
    {
        return _name;
    }
    
    bool isRunning()
    {
        return _flag;
    }
    //线程开始运行
    bool Start()
    {
        int n = pthread_create(&_tid, nullptr, ThreadRoutine, (void*)this);
        if(!n)
        {
            _flag = true;
            return true;
        }
        else
            return false;
    }

    //回收线程
    bool Join()
    {
        if(!_flag) return true;
        int n = pthread_join(_tid, nullptr);
        if(!n)
        {
            _flag = true;
            return true;
        }
        else
            return false;
    }
private:
    pthread_t _tid; //线程tid
    std::string _name; //线程名称
    func_t<T> _func; //线程执行的函数
    bool _flag; //线程是否正在运行
    T _data; //_func的参数
};

test.cc

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <vector>
#include "myThread.hpp"

std::string getThreadName()
{
    static int count = 1;
    char buffer[64];
    snprintf(buffer, sizeof(buffer), "thread-%d", count++);
    return buffer;
}

void Print(int num)
{
    while (num)
    {
        std::cout << "hello world: " << num-- << std::endl;
        sleep(1);
    }
}

int main()
{
    const int num = 5;
    std::vector<Thread<int>> threads;
    for(int i = 0; i < num; i++)
    {
        Thread<int> thread(getThreadName(), Print, 5);
        threads.push_back(thread);
    }

    for (auto &t : threads)
    {
        std::cout << t.getThreadName() << ", is running: " << t.isRunning() << std::endl;
        t.Start();
        std::cout << t.getThreadName() << ", is running: " << t.isRunning() << std::endl;
    }

    for (auto &t : threads)
    {
        t.Join();
    }

    return 0;
}

创建一个线程:

创建多个线程:


Linux线程互斥

首先模拟一段多线程抢票的例子观察现象:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <vector>
#include "myThread.hpp"

int ticket = 1000;//全局共享资源

std::string getThreadName()
{
    static int count = 1;
    char buffer[64];
    snprintf(buffer, sizeof(buffer), "thread-%d", count++);
    return buffer;
}

void getTicket(std::string name)
{
    while (true)
    {
        if (ticket > 0)
        {
            usleep(1000); // 充当抢票时间
            printf("%s get a ticket: %d\n", name.c_str(), ticket);
            ticket--;
        }
        else
        {
            break;
        }
    }

    //... TODO
}

int main()
{
    const int num = 5;
    std::vector<Thread<std::string>> threads;
    for (int i = 0; i < num; i++)
    {
        std::string name = getThreadName();
        Thread<std::string> thread(name, getTicket, name);
        threads.push_back(thread);
    }

    for (auto &t : threads)
    {
        t.Start();
    }

    for (auto &t : threads)
    {
        t.Join();
    }
 
    return 0;
}

运行之后会发现不合理 的现象, 票出现了非正值

进程线程间的互斥相关概念

临界资源: 多线程执行流共享的资源 就叫做临界资源.(ticket)
临界区: 每个线程内部, 访问临界资源的代码 , 就叫做临界区.(对ticket做修改的代码块)
互斥: 任何时刻 , 互斥保证有且只有一个执行流进入临界区 , 访问临界资源, 通常对临界资源起保护作用. 互斥保证了此时临界区的资源是被串行地访问, 而不是并发地访问.
原子性 (后面讨论如何实现): 不会被任何调度机制打断的操作, 该操作只有两态, 要么完成, 要么未完成.

  • 原子性表述的一种: 对资源进行操作, 如果只用一条汇编就能完成, 那么就说该操作具有原子性.

初步解释原子性: 比如--操作就**不是原子操作,**这条语句其实对应三条汇编指令:

load :将 a 从内存加载到寄存器中

update : 更新寄存器里面的值, 执行-1操作(inc)

store :将新值, 从寄存器写回 a 的内存地址

在这三条汇编语句的执行过程中, 都有可能会因为线程切换而中断, 假如线程A对a要执行++操作, 刚执行完inc指令还没有把a写入内存就被切换了, 线程B的函数把 a 修改为了100, 并写回了内存, 线程A又被切换回来, 继续执行之前的指令, 把a写入内存, 这样B之前的工作就相当于白做了.

分析: 为什么会出现不合理的结果(票为负数)?

  1. 假如当前CPU是单核的. 现在ticket的值为1, if (ticket > 0) 语句判断条件为真以后, 在 usleep 这个模拟漫长业务的过程中, 该线程时间片到了, 会并发的切换到其他线程, 并把上下文保存到PCB中. 但是内存中ticket的值没有被改变, 仍是 1, 所以切换到其它线程后, CPU也从内存中读到了 1 , if判断也成立了.
  1. 我们上面说过, --tickets需要三步才能完成, 包括 1.读取数据到寄存器, 2.寄存器数据减一, 3.将寄存器数据写回内存.

此时每一个线程都进入了if语句框, 线程thread1再次被CPU执行. 此时内存中的tickets为1, 寄存器ebx读取数据变为1. 此时执行--tickets, 寄存器内数据变为0, 最后将0写回到内存的tickets中.

thread2线程也被再次唤醒, 再次读取tickets为0, 减一得到-1, 再将-1写回内存中. 后面的thread3、thread4、thread5也是这样的流程, 最后内存中的tickets经过五次减一变成了-4, 这就出现了负数.

要解决这个问题, 就要对临界区的代码进行加锁!


互斥锁

要想解决多线程的数据不一致问题需要做到以下几点:

  • 代码必须有互斥 行为, 当一个线程 进入临界区 执行代码时, 不允许其他线程进入该临界区.
  • 如果有多个线程同时请求执行临界区代码, 并且临界区没有线程在执行代码, 那么只允许一个线程进入该临界区
  • 如果线程不在临界区中执行代码,那么该线程也不能阻止其他线程进入临界区.

其实做到上面三点只需要一把互斥锁, 你可以将锁看作一个通行证, 持有锁的线程才能进入临界区中执行代码, 其他线程不持有锁, 无法进入该临界区.

加锁本质就是让共享资源临界资源化, 多个线程串行访问共享资源, 从而保护共享资源的安全.

互斥锁本质上就是一个类(class pthread_mutex_t),可以构造对象pthread_mutex_t mutex, mutex就是互斥锁对象。

互斥锁初始化

pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;

如果是全局或static修饰的锁, 使用上面语句初始化锁

如果是局部的锁, 用下面两个函数初始化和销毁锁:

int pthread_mutex_init(pthread_mutex_t *restrict mutex,const pthread_mutexattr_t *restrict attr);

头文件:pthread.h

功能:初始化互斥锁.

参数:pthread_mutex_t *restrict mutex表示需要被初始化的锁的地址, const pthread_mutexattr_t *restrict attr表示锁的属性, 一般都为nullptr

返回值:取消成功返回0,取消失败返回错误码。

int pthread_mutex_destroy(pthread_mutex_t *mutex);

头文件:pthread.h

功能:销毁互斥锁。

参数:pthread_mutex_t *mutex表示需要被销毁的锁的地址。

返回值:销毁成功返回0,失败返回错误码。

加锁和解锁

int pthread_mutex_lock(pthread_mutex_t *mutex);

头文件:pthread.h

功能:对lock到unlock的部分代码加锁(仅允许线程串行)

参数:mutex表示需要加锁的锁指针

返回值:加锁成功返回0,失败返回错误码。

int pthread_mutex_unlock(pthread_mutex_t *mutex);

头文件:pthread.h

功能:标识走出lock到unlock的部分代码解锁(恢复并发)

参数:mutex表示需要解锁的锁指针

返回值:解锁成功返回0, 失败返回错误码。

cpp 复制代码
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_lock(lock);
//临界区
//...
pthread_mutex_unlock(lock);

先写一个全局锁的实现:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <vector>
#include "myThread.hpp"

int ticket = 1000;
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER;//有了锁, 定义了并被初始化, 锁也是全局的

std::string getThreadName()
{
    static int count = 1;
    char buffer[64];
    snprintf(buffer, sizeof(buffer), "thread-%d", count++);
    return buffer;
}

void getTicket(std::string name)
{
    while (true)
    {
        // 非临界区代码!
        // 2. 加锁是由程序员自己保证的!规则是临界区都必须先申请锁
        // 3. 根据互斥的定义,任何时刻,只允许一个线程申请锁成功!多个线程申请锁失败,失败的线程怎么办?在mutex上进行阻塞,本质就是等待!
        pthread_mutex_lock(&mutex);//1. 申请锁本身是原子的,是安全的
        if (ticket > 0)// 4. 一个线程在临界区中访问临界资源的时候,可不可能发生切换?可能, 而且切换后其它进程也无法访问临界区, 因为没有解锁.
        {
            usleep(1000); // 充当抢票时间
            printf("%s get a ticket: %d\n", name.c_str(), ticket);
            ticket--;
            pthread_mutex_unlock(&mutex);
        }

        else
        {
            pthread_mutex_unlock(&mutex);
            break;
        }
    }

    //... TODO
}

int main()
{
    const int num = 5;
    std::vector<Thread<std::string>> threads;
    for (int i = 0; i < num; i++)
    {
        std::string name = getThreadName();
        Thread<std::string> thread(name, getTicket, name);
        threads.push_back(thread);
    }

    for (auto &t : threads)
    {
        t.Start();
    }

    for (auto &t : threads)
    {
        t.Join();
    }

    return 0;
}

比如我们的那段 getTicket 临界区其实是 if(ticket > 0) 对应的代码块, 所以在执行临界区代码之前先加锁, 执行完之后再解锁, 注意在else里也要写一次解锁, 给ticket为0时解锁.

运行之后可以发现ticket不再出现非正数, 但是运行速度也明显变慢了, 因为临界区是串行执行.

申请一个局部的锁, 由于我们getTicket函数里需要用到多个参数(局部锁和线程名字), 所以这里封装一个Thread_Data作为参数:

cpp 复制代码
#include <iostream>
#include <unistd.h>
#include <vector>
#include "myThread.hpp"

int ticket = 1000;

class Thread_Data
{
public:
    Thread_Data(const std::string& name, pthread_mutex_t* mutex)
    :_name(name)
    ,_pmutex(mutex)
    {}

public:
    std::string _name;
    pthread_mutex_t* _pmutex;
};

std::string getThreadName()
{
    static int count = 1;
    char buffer[64];
    snprintf(buffer, sizeof(buffer), "thread-%d", count++);
    return buffer;
}

void getTicket(Thread_Data* td)
{
    while (true)
    {
        // 非临界区代码!
        // 2. 加锁是由程序员自己保证的!规则是临界区都必须先申请锁
        // 3. 根据互斥的定义,任何时刻,只允许一个线程申请锁成功!多个线程申请锁失败,失败的线程怎么办?在mutex上进行阻塞,本质就是等待!
        
        pthread_mutex_lock(td->_pmutex);//1. 申请锁本身是原子的,是安全的
        if (ticket > 0)// 4. 一个线程在临界区中访问临界资源的时候,可不可能发生切换?可能, 而且切换后其它进程也无法访问临界区, 因为没有解锁.
        {
            usleep(1000); // 充当抢票时间
            printf("%s get a ticket: %d\n", td->_name.c_str(), ticket);
            ticket--;
            pthread_mutex_unlock(td->_pmutex);
        }

        else
        {
            pthread_mutex_unlock(td->_pmutex);
            break;
        }
    }

    //... TODO
}



int main()
{
    pthread_mutex_t mutex;
    pthread_mutex_init(&mutex, nullptr);

    const int num = 5;
    std::vector<Thread<Thread_Data*>> threads;
    std::vector<Thread_Data*> tds;

    for (int i = 0; i < num; i++)
    {
        std::string name = getThreadName();
        Thread_Data* td = new Thread_Data(name, &mutex);
        Thread<Thread_Data*> thread(name, getTicket, td);
        threads.push_back(thread);
        tds.push_back(td);
    }

    for (auto &t : threads)
    {
        t.Start();
    }

    for (auto &t : threads)
    {
        t.Join();
    }

    pthread_mutex_destroy(&mutex);
    for(auto& td : tds)
    {
        delete td;
    }

    return 0;
}

效果是一样的:

为了简化加锁解锁的过程, 我们可以自己封装一个锁Mutex和锁的守卫LockGuard :

cpp 复制代码
#pragma once

#include <pthread.h>

// 不定义锁,默认认为外部会给我们传入锁对象
class Mutex
{
public:
    Mutex(pthread_mutex_t *lock)
    :_lock(lock)
    {}

    void Lock()
    {
        pthread_mutex_lock(_lock);
    }

    void Unlock()
    {
        pthread_mutex_unlock(_lock);
    }

    ~Mutex()
    {}

private:
    pthread_mutex_t *_lock;
};

class LockGuard
{
public:
    LockGuard(pthread_mutex_t *lock)
    : _mutex(lock)
    {
        _mutex.Lock();
    }
    ~LockGuard()
    {
        _mutex.Unlock();
    }
private:
    Mutex _mutex;
};

只需要在getTicket函数里需要加锁的地方定义一个LockGuard对象, 并用{}套起来, 这样lg的构造函数会为我们自动加锁, lg出代码块调用析构函数会自动解锁.

cpp 复制代码
void getTicket(Thread_Data *td)
{
    while (true)
    {
        {
            LockGuard lg(td->_pmutex);
            if (ticket > 0) 
            {
                usleep(1000); // 充当抢票时间
                printf("%s get a ticket: %d\n", td->_name.c_str(), ticket);
                ticket--;
            }

            else
                break;
        }

        //... TODO
    }

}

加锁原则:

  1. 我们要尽可能地给少的代码块加锁.

  2. 一般加锁, 都是给临界区加锁

  3. 谁加锁, 就由谁来解锁

加锁对线程的影响

对上面的现象做进一步解释, 用pthread_mutex_lock函数加锁, 一个线程如果成功申请锁, 那么它就会继续向下执行, 如果申请不成功, 它就会在加锁处阻塞.

所以我们此时就能理解CPU排队处理线程和串行的关系了:

  1. 当一个线程申请锁成功, 进入临界区访问临界资源, 其他线程要想进入临界区只能阻塞等待, 等待该进程将锁释放.

  2. 即使线程切换了也没关系, 因为锁还在该线程的"手里", 其他线程仍然无法申请锁成功.

所以站在其他线程的角度, 临界区的代码只有两种状态, 被加锁和没被加锁, 不存在其它中间状态, 所以被加锁后的临界区是原子的.

补充: 如果使用pthread_mutex_trylock加锁,如果互斥量当前没有被其他线程锁定,pthread_mutex_trylock 会成功锁定它并立即返回. 但是, 如果互斥量已经被其他线程锁定, pthread_mutex_trylock 会立即返回错误, 而不是阻塞调用线程等待互斥量变得可用.


锁的本质

锁必须让所有线程都看到, 所以锁本身就是共享资源. 那谁来保护锁的安全呢?

锁是通过加锁和解锁操作的原子性来保证自身的安全的

为了实现互斥锁操作, 大多数体系结构都提供了swapexchange 指令, 该指令的作用是把寄存器内存单元的数据相交换, 由于只有一条指令, 保证了原子性, 即使是多处理器平台,访问内存的 总线周期也有先后, 一个处理器上的交换指令执行时另一个处理器的交换指令只能等待总线周期.

lock和unlock的伪代码:

假设有多个线程, 每个线程中都有加锁的代码:

首先CPU开始执行线程thread1的代码, 首先 movb $0,%al, 把0写入al寄存器:

然后交换al寄存器和mutex在内存中的内容, 由于此时thread1是第一个加锁的线程, 所以mutex内存中是1. 由于寄存器的内容每个线程都有一份, 属于线程自己的上下文, **是线程的私有数据,**所以此时al寄存器为1就相当于thread1拿到了这把"锁":

al寄存器的内容>0, 返回0, 代表加锁成功:

那么加锁和解锁操作的原子性体现在哪呢?

在上面那几条指令的任何一条指令处线程被切换, 都不会引起问题.

假如thread1 在echgb %al, mutex执行后就被切换了, thread2重新执行了一遍加锁的指令, 最后在al中存放的也只是0, 只能走到 else 挂起等待. 而因为thread1的上下文被自己保存,下一次线程切换时, thread1依旧拿着这把"锁"继续向下执行if语句, 加锁成功.

经过上面过程的描述,我们不难发现发现:

  • 锁只能被一个线程持有, 而且由于加锁是一条xchange汇编代码, 操作是原子性的, 也不需要担心线程切换的事情.
  • 一旦一个线程申请到锁, 因为即使该线程被切走, 锁还是在它的上下文数据中. 所以, 其他线程无法拿到锁,只能挂起等待, 只有等锁被释放时才能申请.
  • 锁的工作本质上就是锁类变量中的一个标志位1在不同进程间传递的过程, 只有申请到该标志位, 或者说持有锁的线程才能执行. 形象地说, 利用锁达到线程串行类似于很多人抢一张入场券.
  • 释放锁的过程对原子性的要求不高, 因为只有持有锁的线程才能释放锁, 未申请到锁的线程都在挂起.

可重入VS线程安全

可重入和不可重入对应的是函数 的特征, 线程安全与否描述的是线程的特征.

重入

**重入:**同一个函数被不同的执行流调用, 当前一个流程还没有执行完,就有其他的执行流再次进入,我们称之为重入.

一个函数在重入的情况下, 运行结果不会出现 任何不同或者任何问题, 则该函数被称为**可重入函数.**否则, 是不可重入函数.

常见不可重入的情况

调用了malloc/free函数, 因为malloc函数是用全局链表来管理堆的

调用了标准I/O库函数, 标准I/O库的很多实现都以不可重入的方式使用全局数据结构

可重入函数体内使用了静态的数据结构

常见可重入的情况

不使用全局变量或静态变量

不使用用malloc或者new开辟出的空间

不调用不可重入函数

不返回静态或全局数据,所有数据都有函数的调用者提供

使用本地数据,或者通过制作全局数据的本地拷贝来保护全局数据

线程安全

线程安全: 多个线程并发同一段代码 时, 不会出现不同的结果. 否则是线程不安全的.

常见的线程安全的情况

  • 每个线程对全局变量或者静态变量只有读取的权限,而没有写入的权限,一般来说这些线程是安全的.
  • 类或者接口对于线程来说都是原子操作.
  • 多个线程之间的切换不会导致该接口的执行结果存在二义性.

常见的线程不安全的情况:

  • 不保护共享变量(全局, 静态变量)的函数
  • 函数状态随着被调用, 状态发生变化的函数
  • 返回指向静态变量指针的函数
  • 调用线程不安全函数的函数

可重入与线程安全的联系:

  • 函数可重入, 就是线程安全的. 这样的代码没有全局或静态变量, 不会产生数据不一致的问题
  • 函数不可重入, 如果多个线程并发, 就有可能引发线程安全问题. 对不可重入函数的全局变量需要加锁保护.
  • 如果一个函数中有不加锁保护的全局变量或静态变量, 那这个函数既不可重入, 多线程并发也不能保证线程安全.

可重入与线程安全的区别:

  • 可重入说的是函数的中性属性, 而线程安全说的是线程并发是否会出问题
  • 可重入函数是线程安全函数的一种, 因为不存在全局或者静态变量
  • 线程安全不一定保证函数可重入的, 而可重入函数又一定是线程安全的. 因为线程安全的情况可能是对全局变量等进行了加锁.
  • 由于线程安全可以通过加锁实现, 所以线程安全的情况比可重入要多

情况不需要记忆, 只需要知道结论:

  1. 多执行流并发访问代码块不出现问题(程序崩溃, 数据不一致), 是线程安全的.

  2. 线程调用可重入函数一定是线程安全的


死锁

死锁的概念和必要条件

死锁是指在一组进程中的各个进程均占有不会释放 的资源, 但因互相申请 被其他进程所占用不会释放的资源而处于的一种永久等待状态.

死锁形成的四个必要条件:

互斥条件: 一个资源每次只能被一个执行流使用. (只要用到锁就必定有互斥).
请求与保持条件: 一个执行流因请求资源而阻塞时, 对已获得的资源保持不放(一个执行流申请其他锁时, 不释放自己已经持有的锁)
不剥夺条件: 一个执行流已获得的资源, 在末使用完之前, 不能强行剥夺.(已经持有锁的执行流, 在它不主动释放锁前, 其它线程不能强行剥夺它的锁.) 比如线程A有pthread_mutex_lock(&mutex1), 线程B不能强行解锁pthread_mutex_unlock(&mutex1).
循环等待条件: 若干执行流之间形成一种头尾相接的循环等待资源的关系

假设线程A当前有一把锁lock1, 线程B有一把锁lock2, 但是线程A和线程B都需要同时拥有两把锁才能继续向下执行. 在上面的四个条件下, B线程申请lock1失败, 在申请lock1时阻塞; A线程申请lock2失败, 在申请lock2时阻塞. 此时A线程和B线程都在对方的锁处等待, 所以两个线程都不会向下运行了.

思考, 一把锁也会产生死锁吗?

会, 如果在解锁的时候误把unlock写成lock, 就是一个线程申请两次加锁, 不仅自己的线程会被阻塞, 其它线程也会阻塞, 造成死锁.

避免死锁

解决或者避免死锁的方法就是至少破坏4个死锁必要条件里的1个:

一、破坏条件1互斥条件 就是直接不用锁, 但是锁是为了保护资源才使用的, 所以在不得不用锁的前提下, 只能破坏后3个条件.

二、

1.**请求与保持条件--**一个执行流因请求资源而阻塞时, 对已获得的资源保持不放.

破坏此条件就是要把将自己的资源释放掉, 如果线程曾经申请成功了一把锁, 在申请另一把锁失败时, 把自己的锁全部释放掉, 回退到没有申请锁的阶段. 假设A要申请lock2, 申请失败, 那就把lock1释放掉.

**2. 不剥夺条件--**个执行流已获得的资源, 在末使用完之前, 不能强行剥夺.

在一个线程申请锁失败时, 解锁并强行加锁.

**3. 循环等待条件--**若干执行流之间形成一种头尾相接的循环等待资源的关系

建议按照同样的次序申请锁. 线程A按照lock1和lock2的顺序申请锁, 线程B也按照这个次序. 而不是图中那样.

总结: 尽量把锁资源, 按照顺序一次性申请给线程.


避免死锁算法

  • 死锁检测算法(了解)
  • 银行家算法(了解)
相关推荐
watermelonoops3 分钟前
Deepin和Windows传文件(Xftp,WinSCP)
linux·ssh·deepin·winscp·xftp
疯狂飙车的蜗牛1 小时前
从零玩转CanMV-K230(4)-小核Linux驱动开发参考
linux·运维·驱动开发
远游客07133 小时前
centos stream 8下载安装遇到的坑
linux·服务器·centos
马甲是掉不了一点的<.<3 小时前
本地电脑使用命令行上传文件至远程服务器
linux·scp·cmd·远程文件上传
jingyu飞鸟3 小时前
centos-stream9系统安装docker
linux·docker·centos
唐诺3 小时前
几种广泛使用的 C++ 编译器
c++·编译器
超爱吃士力架4 小时前
邀请逻辑
java·linux·后端
冷眼看人间恩怨4 小时前
【Qt笔记】QDockWidget控件详解
c++·笔记·qt·qdockwidget
红龙创客4 小时前
某狐畅游24校招-C++开发岗笔试(单选题)
开发语言·c++
Lenyiin4 小时前
第146场双周赛:统计符合条件长度为3的子数组数目、统计异或值为给定值的路径数目、判断网格图能否被切割成块、唯一中间众数子序列 Ⅰ
c++·算法·leetcode·周赛·lenyiin