取消一个长时间运行的正则表达式匹配?

23

假设我正在运行一个服务,用户可以提交一个正则表达式来搜索大量数据。如果用户提交了一个非常慢的正则表达式(例如,Matcher.find()需要花费几分钟才能返回),我希望有一种方法来取消该匹配。我能想到的唯一方法是使用另一个线程监视匹配过程花费的时间,并在必要时使用Thread.stop()来取消它。

成员变量:

long REGEX_TIMEOUT = 30000L;
Object lock = new Object();
boolean finished = false;
Thread matcherThread;

匹配线程:

try {
    matcherThread = Thread.currentThread();

    // imagine code to start monitor thread is here

    try {
        matched = matcher.find();
    } finally {
        synchronized (lock) {
            finished = true;
            lock.notifyAll();
        }
    }
} catch (ThreadDeath td) {
    // send angry message to client
    // handle error without rethrowing td
}

监视线程:

synchronized (lock) {
    while (! finished) {
        try {
            lock.wait(REGEX_TIMEOUT);

            if (! finished) {
                matcherThread.stop();
            }
        } catch (InterruptedException ex) {
            // ignore, top level method in dedicated thread, etc..
        }
    }
}

我已经阅读了java.sun.com/j2se/1.4.2/docs/guide/misc/threadPrimitiveDeprecation.html,我认为在通过同步控制ThreadDeath抛出的位置并处理它的情况下,这种用法是安全的,并且唯一可能受损的对象是我的Pattern和Matcher实例,而这些实例将被丢弃。我认为这会破坏Thread.stop(),因为我没有重新抛出错误,但我并不希望线程死掉,只是想中止find()方法。

迄今为止,我已成功避免使用这些不推荐使用的API组件,但Matcher.find()似乎无法中断,并且可能需要很长时间才能返回。有更好的方法来做到这一点吗?


1
就我个人而言,我认为允许用户提交正则表达式作为搜索条件是一个不好的主意。这可能适用于程序员,但不适用于终端用户... - Mitch Wheat
1
如果您接受任意正则表达式,那么您应该预计会遭受DoS攻击。 - Tom Hawtin - tackline
3
并非所有的代码都暴露在公共网络中,你需要担心 DoS 攻击。 - Jared
7个回答

47

来自Heritrix:(crawler.archive.org)

/**
 * CharSequence that noticed thread interrupts -- as might be necessary 
 * to recover from a loose regex on unexpected challenging input. 
 * 
 * @author gojomo
 */
public class InterruptibleCharSequence implements CharSequence {
    CharSequence inner;
    // public long counter = 0; 

    public InterruptibleCharSequence(CharSequence inner) {
        super();
        this.inner = inner;
    }

    public char charAt(int index) {
        if (Thread.interrupted()) { // clears flag if set
            throw new RuntimeException(new InterruptedException());
        }
        // counter++;
        return inner.charAt(index);
    }

    public int length() {
        return inner.length();
    }

    public CharSequence subSequence(int start, int end) {
        return new InterruptibleCharSequence(inner.subSequence(start, end));
    }

    @Override
    public String toString() {
        return inner.toString();
    }
}

使用此类包装您的CharSequence,线程中断将会生效...


1
如果将异常位移出charAt,速度会稍微快一些,尽管真正的问题可能是模式效率低下而不是目标文本过大。 - Tom Hawtin - tackline
1
所有回溯正则表达式算法都被广泛认知会在指定某些邪恶模式时变得病态。正如Tom所说,输入字符串的读取不太可能导致巨大的正则表达式运行时间,但更可能是由于正则表达式算法中的过度回溯引起的。可能会出现正则表达式在读取另一个字符之前卡住了很久的情况(我之前在Perl中就见过这种情况),因此有时中断可能不起作用。 - Benj
@Kris charAt从未被调用过。可能是什么问题?请看我的问题:http://stackoverflow.com/questions/17674839/android-matcher-find-infinite - NullPointerException
我同意,这样行不通。正则表达式匹配器将charsequence复制到字符串中。 - Snicolas
@Benj,当前的Java Matcher实现在回溯时实际上会再次调用charAt,即使在小字符串中也会被调用数百万次。我刚刚进行了一项测试,在2-3秒内使用字符串xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx和模式(x+x+)+y,它调用了9000万次charAt(没有预热)。我认为如果按照微基准测试的指南在适当的环境下进行测试,它将在给定的时间内执行更多次数。 - DGoiko

6

稍作修改即可避免使用额外线程:

public class RegularExpressionUtils {

    // demonstrates behavior for regular expression running into catastrophic backtracking for given input
    public static void main(String[] args) {
        Matcher matcher = createMatcherWithTimeout(
                "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "(x+x+)+y", 2000);
        System.out.println(matcher.matches());
    }

    public static Matcher createMatcherWithTimeout(String stringToMatch, String regularExpression, int timeoutMillis) {
        Pattern pattern = Pattern.compile(regularExpression);
        return createMatcherWithTimeout(stringToMatch, pattern, timeoutMillis);
    }

    public static Matcher createMatcherWithTimeout(String stringToMatch, Pattern regularExpressionPattern, int timeoutMillis) {
        CharSequence charSequence = new TimeoutRegexCharSequence(stringToMatch, timeoutMillis, stringToMatch,
                regularExpressionPattern.pattern());
        return regularExpressionPattern.matcher(charSequence);
    }

    private static class TimeoutRegexCharSequence implements CharSequence {

        private final CharSequence inner;

        private final int timeoutMillis;

        private final long timeoutTime;

        private final String stringToMatch;

        private final String regularExpression;

        public TimeoutRegexCharSequence(CharSequence inner, int timeoutMillis, String stringToMatch, String regularExpression) {
            super();
            this.inner = inner;
            this.timeoutMillis = timeoutMillis;
            this.stringToMatch = stringToMatch;
            this.regularExpression = regularExpression;
            timeoutTime = System.currentTimeMillis() + timeoutMillis;
        }

        public char charAt(int index) {
            if (System.currentTimeMillis() > timeoutTime) {
                throw new RuntimeException("Timeout occurred after " + timeoutMillis + "ms while processing regular expression '"
                                + regularExpression + "' on input '" + stringToMatch + "'!");
            }
            return inner.charAt(index);
        }

        public int length() {
            return inner.length();
        }

        public CharSequence subSequence(int start, int end) {
            return new TimeoutRegexCharSequence(inner.subSequence(start, end), timeoutMillis, stringToMatch, regularExpression);
        }

        @Override
        public String toString() {
            return inner.toString();
        }
    }

}

感谢dawce在回答一个不必要复杂的问题时,向我指出了这个解决方案。有关此问题,请单击此处

1
+1 建议:currentTimeMillis() 是一个相当昂贵的操作。添加一个计数器,只有在每第 N 次调用 charAt() 时才调用它。 - Aaron Digulla
很棒的答案。使用这个的人都会想要抛出自定义异常而不是RuntimeException。 - Alkanshel
是的,确实这是一个很好的解决方案,但仅限于抛出RTE。如果我想发送自定义异常,我认为我们需要在TimeoutRegexCharSequence类的顶部再创建一个包装器。 - Codex

1
一个长时间运行的模式匹配过程可以使用以下方法停止。
  • Create StateFulCharSequence class which manages the state of pattern matching. When that state is changed, an exception is thrown on the next call to charAt method.
  • That state change can be scheduled using ScheduledExecutorService with a required timeout.
  • Here pattern matching is happening in the main thread and there is no need to check the thread interrupt state every time.

    public class TimedPatternMatcher {
    public static void main(String[] args) {
        ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);
        Pattern pattern = Pattern.compile("some regex pattern");
        StateFulCharSequence stateFulCharSequence = new StateFulCharSequence("some character sequence");
        Matcher matcher = pattern.matcher(stateFulCharSequence);
        executorService.schedule(stateFulCharSequence, 10, TimeUnit.MILLISECONDS);
        try {
            boolean isMatched = matcher.find();
        }catch (Exception e) {
            e.printStackTrace();
        }
    
    }
    
    /*
    When this runnable is executed, it will set timeOut to true and pattern matching is stopped by throwing exception.
     */
    public static class StateFulCharSequence implements CharSequence, Runnable{
        private CharSequence inner;
    
        private boolean isTimedOut = false;
    
        public StateFulCharSequence(CharSequence inner) {
            super();
            this.inner = inner;
        }
    
        public char charAt(int index) {
            if (isTimedOut) {
                throw new RuntimeException(new TimeoutException("Pattern matching timeout occurs"));
            }
            return inner.charAt(index);
        }
    
        @Override
        public int length() {
            return inner.length();
        }
    
        @Override
        public CharSequence subSequence(int start, int end) {
            return new StateFulCharSequence(inner.subSequence(start, end));
        }
    
        @Override
        public String toString() {
            return inner.toString();
        }
    
        public void setTimedOut() {
            this.isTimedOut = true;
        }
    
        @Override
        public void run() {
            this.isTimedOut = true;
        }
    }}
    

这个解决方案并不能真正解决问题,如果charAt需要很长时间,那么你无法保证定义的超时时间。 - user3237183
@lssilva,即使在短字符串中,charAt每秒被调用数百万次。如果您不相信,请进行自己的测试。 - DGoiko
@DGoiko 我刚刚做了一个测试,创建了一个生成随机字符串的方法(https://www.geeksforgeeks.org/generate-random-string-of-given-size-in-java/),并运行了以下代码:println(System.currentTimeMillis()) println(getAlphaNumericString(Int.MaxValue - 1000).charAt(900000)) println(System.currentTimeMillis()) 它花费了将近1分钟。 - user3237183
@lssilva 我的意思是读取正则表达式中的字符。请从 https://dev59.com/gXNA5IYBdhLWcg3wkuzO#11348374 中获取代码,并在 charAt 中设置一个计数器,以查看在所需的时间内读取了多少次。您可以使用它测试任何长时间运行的正则表达式。 - DGoiko

0
也许你需要一个实现NFA算法的新库。
NFA算法比Java标准库使用的算法快数百倍。
而Java标准库对输入正则表达式很敏感,这可能导致问题发生——某些输入会让CPU运行多年。
通过它使用的步骤,可以通过NFA算法设置超时。它比线程解决方案更有效。相信我,我曾使用线程超时来解决一个相关问题,性能非常糟糕。我最终通过修改我的算法实现的主循环来解决了这个问题。我在主循环中插入了一些检查点来测试时间。
详细信息请参见:https://swtch.com/~rsc/regexp/regexp1.html

0
另一个解决方法是限制匹配器的region,然后调用find(),重复执行直到线程被中断或找到匹配项。

只有在您能够保证潜在匹配项的大小和边界时,才能使用该方法。这可能不适用于涉及任意表达式的问题。 - AndrewF

0

在执行正则表达式之前,检查用户提交的正则表达式是否包含“恶意”模式,可以使用一个或多个正则表达式模式进行检查(这可以通过调用一个名为“prior to conditional execution of the regex”的方法来实现):

这个正则表达式:

\(.+\+\)[\+\*]

将匹配:

(a+)+
(ab+)+
([a-zA-Z]+)*

这个正则表达式:

\((.+)\|(\1\?|\1{2,})\)\+

将匹配:

(a|aa)+
(a|a?)+

这个正则表达式:

\(\.\*.\)\{\d{2,}\}

将匹配:

(.*a){x} for x \> 10

关于正则表达式和正则表达式DoS,我可能有点天真,但我不禁想到,对已知的“恶意”模式进行一些预筛选将在执行时防止问题方面发挥很大作用,特别是如果所涉及的正则表达式是由最终用户提供的输入。上述模式可能还不够精细,因为我远非正则表达式专家。这只是一个思路,因为我在其他地方找到的所有内容似乎都表明它无法完成,并且侧重于在正则表达式引擎上设置超时或限制允许执行的迭代次数。


0

我添加了一个计数器,以便检查每n次charAt读取,以减少开销。

注意:

有些人表示可能不会频繁调用carAt。我只是添加了foo变量,以演示charAt被调用的次数以及它足够频繁。如果您要在生产中使用此功能,请删除该计数器,因为它会降低性能并在服务器上长时间运行时溢出long。在此示例中,大约每0.8秒调用30百万次charAt(未经过适当的微基准测试条件测试,仅为概念证明)。如果您想要更高的精度,则可以设置较低的checkInterval,但会牺牲性能(System.currentTimeMillis() > timeoutTime比长期使用if子句更昂贵)。

import java.util.regex.Matcher;
import java.util.regex.Pattern;

import com.goikosoft.test.RegexpTimeoutException;

/**
 * Allows to create timeoutable regular expressions.
 *
 * Limitations: Can only throw RuntimeException. Decreases performance.
 *
 * Posted by Kris in stackoverflow.
 *
 * Modified by dgoiko to  ejecute timeout check only every n chars.
 * Now timeout < 0 means no timeout.
 *
 * @author Kris https://dev59.com/gXNA5IYBdhLWcg3wkuzO#910798
 *
 */
public class RegularExpressionUtils {

    public static long foo = 0;

    // demonstrates behavior for regular expression running into catastrophic backtracking for given input
    public static void main(String[] args) {
        long millis = System.currentTimeMillis();
        // This checkInterval produces a < 500 ms delay. Higher checkInterval will produce higher delays on timeout.
        Matcher matcher = createMatcherWithTimeout(
                "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", "(x+x+)+y", 10000, 30000000);
        try {
            System.out.println(matcher.matches());
        } catch (RuntimeException e) {
            System.out.println("Operation timed out after " + (System.currentTimeMillis() - millis) + " milliseconds");
        }
        System.out.print(foo);
    }

    public static Matcher createMatcherWithTimeout(String stringToMatch, String regularExpression, long timeoutMillis,
                                                      int checkInterval) {
        Pattern pattern = Pattern.compile(regularExpression);
        return createMatcherWithTimeout(stringToMatch, pattern, timeoutMillis, checkInterval);
    }

    public static Matcher createMatcherWithTimeout(String stringToMatch, Pattern regularExpressionPattern,
                                                    long timeoutMillis, int checkInterval) {
        if (timeoutMillis < 0) {
            return regularExpressionPattern.matcher(stringToMatch);
        }
        CharSequence charSequence = new TimeoutRegexCharSequence(stringToMatch, timeoutMillis, stringToMatch,
                regularExpressionPattern.pattern(), checkInterval);
        return regularExpressionPattern.matcher(charSequence);
    }

    private static class TimeoutRegexCharSequence implements CharSequence {

        private final CharSequence inner;

        private final long timeoutMillis;

        private final long timeoutTime;

        private final String stringToMatch;

        private final String regularExpression;

        private int checkInterval;

        private int attemps;

        TimeoutRegexCharSequence(CharSequence inner, long timeoutMillis, String stringToMatch,
                                  String regularExpression, int checkInterval) {
            super();
            this.inner = inner;
            this.timeoutMillis = timeoutMillis;
            this.stringToMatch = stringToMatch;
            this.regularExpression = regularExpression;
            timeoutTime = System.currentTimeMillis() + timeoutMillis;
            this.checkInterval = checkInterval;
            this.attemps = 0;
        }

        public char charAt(int index) {
            if (this.attemps == this.checkInterval) {
                foo++;
                if (System.currentTimeMillis() > timeoutTime) {
                    throw new RegexpTimeoutException(regularExpression, stringToMatch, timeoutMillis);
                }
                this.attemps = 0;
            } else {
                this.attemps++;
            }

            return inner.charAt(index);
        }

        public int length() {
            return inner.length();
        }

        public CharSequence subSequence(int start, int end) {
            return new TimeoutRegexCharSequence(inner.subSequence(start, end), timeoutMillis, stringToMatch,
                                                regularExpression, checkInterval);
        }

        @Override
        public String toString() {
            return inner.toString();
        }
    }

}

还有自定义异常,这样你就可以捕获仅仅那个异常,以避免吞噬其他可能会抛出的 RE Pattern / Matcher 异常。

public class RegexpTimeoutException extends RuntimeException {
    private static final long serialVersionUID = 6437153127902393756L;

    private final String regularExpression;

    private final String stringToMatch;

    private final long timeoutMillis;

    public RegexpTimeoutException() {
        super();
        regularExpression = null;
        stringToMatch = null;
        timeoutMillis = 0;
    }

    public RegexpTimeoutException(String message, Throwable cause) {
        super(message, cause);
        regularExpression = null;
        stringToMatch = null;
        timeoutMillis = 0;
    }

    public RegexpTimeoutException(String message) {
        super(message);
        regularExpression = null;
        stringToMatch = null;
        timeoutMillis = 0;
    }

    public RegexpTimeoutException(Throwable cause) {
        super(cause);
        regularExpression = null;
        stringToMatch = null;
        timeoutMillis = 0;
    }

    public RegexpTimeoutException(String regularExpression, String stringToMatch, long timeoutMillis) {
        super("Timeout occurred after " + timeoutMillis + "ms while processing regular expression '"
                + regularExpression + "' on input '" + stringToMatch + "'!");
        this.regularExpression = regularExpression;
        this.stringToMatch = stringToMatch;
        this.timeoutMillis = timeoutMillis;
    }

    public String getRegularExpression() {
        return regularExpression;
    }

    public String getStringToMatch() {
        return stringToMatch;
    }

    public long getTimeoutMillis() {
        return timeoutMillis;
    }

}

基于Andreas的回答。主要功劳应归于他和他的来源。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接